hestia-earth-models 0.65.6__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.
Files changed (51) hide show
  1. hestia_earth/models/config/Cycle.json +2193 -0
  2. hestia_earth/models/config/ImpactAssessment.json +2041 -0
  3. hestia_earth/models/config/Site.json +471 -0
  4. hestia_earth/models/config/__init__.py +71 -0
  5. hestia_earth/models/config/run-calculations.json +42 -0
  6. hestia_earth/models/config/trigger-calculations.json +43 -0
  7. hestia_earth/models/ipcc2019/animal/hoursWorkedPerDay.py +38 -0
  8. hestia_earth/models/mocking/search-results.json +4524 -27
  9. hestia_earth/models/version.py +1 -1
  10. hestia_earth/orchestrator/__init__.py +40 -0
  11. hestia_earth/orchestrator/log.py +62 -0
  12. hestia_earth/orchestrator/models/__init__.py +118 -0
  13. hestia_earth/orchestrator/models/emissions/__init__.py +0 -0
  14. hestia_earth/orchestrator/models/emissions/deleted.py +15 -0
  15. hestia_earth/orchestrator/models/transformations.py +103 -0
  16. hestia_earth/orchestrator/strategies/__init__.py +0 -0
  17. hestia_earth/orchestrator/strategies/merge/__init__.py +42 -0
  18. hestia_earth/orchestrator/strategies/merge/merge_append.py +29 -0
  19. hestia_earth/orchestrator/strategies/merge/merge_default.py +1 -0
  20. hestia_earth/orchestrator/strategies/merge/merge_list.py +103 -0
  21. hestia_earth/orchestrator/strategies/merge/merge_node.py +59 -0
  22. hestia_earth/orchestrator/strategies/run/__init__.py +8 -0
  23. hestia_earth/orchestrator/strategies/run/add_blank_node_if_missing.py +85 -0
  24. hestia_earth/orchestrator/strategies/run/add_key_if_missing.py +9 -0
  25. hestia_earth/orchestrator/strategies/run/always.py +6 -0
  26. hestia_earth/orchestrator/utils.py +116 -0
  27. {hestia_earth_models-0.65.6.dist-info → hestia_earth_models-0.65.7.dist-info}/METADATA +27 -5
  28. {hestia_earth_models-0.65.6.dist-info → hestia_earth_models-0.65.7.dist-info}/RECORD +51 -7
  29. tests/models/ipcc2019/animal/test_hoursWorkedPerDay.py +22 -0
  30. tests/models/test_config.py +115 -0
  31. tests/orchestrator/__init__.py +0 -0
  32. tests/orchestrator/models/__init__.py +0 -0
  33. tests/orchestrator/models/emissions/__init__.py +0 -0
  34. tests/orchestrator/models/emissions/test_deleted.py +21 -0
  35. tests/orchestrator/models/test_transformations.py +29 -0
  36. tests/orchestrator/strategies/__init__.py +0 -0
  37. tests/orchestrator/strategies/merge/__init__.py +0 -0
  38. tests/orchestrator/strategies/merge/test_merge_append.py +33 -0
  39. tests/orchestrator/strategies/merge/test_merge_default.py +7 -0
  40. tests/orchestrator/strategies/merge/test_merge_list.py +327 -0
  41. tests/orchestrator/strategies/merge/test_merge_node.py +95 -0
  42. tests/orchestrator/strategies/run/__init__.py +0 -0
  43. tests/orchestrator/strategies/run/test_add_blank_node_if_missing.py +114 -0
  44. tests/orchestrator/strategies/run/test_add_key_if_missing.py +14 -0
  45. tests/orchestrator/strategies/run/test_always.py +5 -0
  46. tests/orchestrator/test_models.py +69 -0
  47. tests/orchestrator/test_orchestrator.py +27 -0
  48. tests/orchestrator/test_utils.py +109 -0
  49. {hestia_earth_models-0.65.6.dist-info → hestia_earth_models-0.65.7.dist-info}/LICENSE +0 -0
  50. {hestia_earth_models-0.65.6.dist-info → hestia_earth_models-0.65.7.dist-info}/WHEEL +0 -0
  51. {hestia_earth_models-0.65.6.dist-info → hestia_earth_models-0.65.7.dist-info}/top_level.txt +0 -0
@@ -1 +1 @@
1
- VERSION = '0.65.6'
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
@@ -0,0 +1,103 @@
1
+ import pydash
2
+ from hestia_earth.schema import UNIQUENESS_FIELDS
3
+
4
+ from hestia_earth.orchestrator.utils import _non_empty_list, update_node_version
5
+ from .merge_node import merge as merge_node
6
+
7
+ _METHOD_MODEL_KEY = 'methodModel.@id'
8
+
9
+
10
+ def _matching_properties(model: dict, node_type: str):
11
+ return UNIQUENESS_FIELDS.get(node_type, {}).get(model.get('key'), [])
12
+
13
+
14
+ def _has_property(value: dict, key: str):
15
+ keys = key.split('.')
16
+ is_list = len(keys) >= 2 and isinstance(pydash.objects.get(value, keys[0]), list)
17
+ values = [
18
+ pydash.objects.get(v, '.'.join(keys[1:])) for v in pydash.objects.get(value, keys[0])
19
+ ] if is_list else [
20
+ pydash.objects.get(value, key)
21
+ ]
22
+ return all([v is not None for v in values])
23
+
24
+
25
+ def _values_have_property(values: list, key: str): return any([_has_property(v, key) for v in values])
26
+
27
+
28
+ def _match_list_el(source: list, dest: list, key: str):
29
+ src_value = sorted(_non_empty_list([pydash.objects.get(x, key) for x in source]))
30
+ dest_value = sorted(_non_empty_list([pydash.objects.get(x, key) for x in dest]))
31
+ return src_value == dest_value
32
+
33
+
34
+ def _match_el(source: dict, dest: dict, keys: list):
35
+ def match(key: str):
36
+ keys = key.split('.')
37
+ src_value = pydash.objects.get(source, key)
38
+ dest_value = pydash.objects.get(dest, key)
39
+ is_list = len(keys) >= 2 and (
40
+ isinstance(pydash.objects.get(source, keys[0]), list) or
41
+ isinstance(pydash.objects.get(dest, keys[0]), list)
42
+ )
43
+ return _match_list_el(
44
+ pydash.objects.get(source, keys[0], []),
45
+ pydash.objects.get(dest, keys[0], []),
46
+ '.'.join(keys[1:])
47
+ ) if is_list else src_value == dest_value
48
+
49
+ source_properties = [p for p in keys if _has_property(source, p)]
50
+ dest_properties = [p for p in keys if _has_property(dest, p)]
51
+
52
+ return all(map(match, source_properties)) if source_properties == dest_properties else False
53
+
54
+
55
+ def _handle_local_property(values: list, properties: list, local_id: str):
56
+ # Handle "impactAssessment.@id" if present in the data
57
+ existing_id = local_id.replace('.id', '.@id')
58
+
59
+ if local_id in properties:
60
+ # remove if not used
61
+ if not _values_have_property(values, local_id):
62
+ properties.remove(local_id)
63
+
64
+ # add if used
65
+ if _values_have_property(values, existing_id):
66
+ properties.append(existing_id)
67
+
68
+ return properties
69
+
70
+
71
+ def _find_match_el_index(values: list, el: dict, same_methodModel: bool, model: dict, node_type: str):
72
+ """
73
+ Find an element in the values that match the new element, based on the unique properties.
74
+ To find a matching element:
75
+
76
+ 1. Update list of properties to handle `methodModel.@id` and `impactAssessment.@id`
77
+ 2. Filter values that have the same unique properties as el
78
+ 3. Make sure all shared unique properties are identical
79
+ """
80
+ properties = _matching_properties(model, node_type)
81
+ properties = list(set(properties + [_METHOD_MODEL_KEY])) if same_methodModel else [
82
+ p for p in properties if p != _METHOD_MODEL_KEY
83
+ ]
84
+ properties = _handle_local_property(values, properties, 'impactAssessment.id')
85
+
86
+ return next((i for i in range(len(values)) if _match_el(values[i], el, properties)), None) if properties else None
87
+
88
+
89
+ def merge(source: list, merge_with: list, version: str, model: dict = {}, merge_args: dict = {}, node_type: str = ''):
90
+ source = source if source is not None else []
91
+
92
+ # only merge node if it has the same `methodModel`
93
+ same_methodModel = merge_args.get('sameMethodModel', False)
94
+ # only merge if the
95
+ skip_same_term = merge_args.get('skipSameTerm', False)
96
+
97
+ for el in _non_empty_list(merge_with):
98
+ source_index = _find_match_el_index(source, el, same_methodModel, model, node_type)
99
+ if source_index is None:
100
+ source.append(update_node_version(version, el))
101
+ elif not skip_same_term:
102
+ source[source_index] = merge_node(source[source_index], el, version, model, merge_args)
103
+ return source
@@ -0,0 +1,59 @@
1
+ import pydash
2
+ from hestia_earth.schema import EmissionMethodTier
3
+
4
+ from hestia_earth.orchestrator.log import logger, logShouldMerge
5
+ from hestia_earth.orchestrator.utils import update_node_version, _average
6
+
7
+ _METHOD_TIER_ORDER = [
8
+ EmissionMethodTier.NOT_RELEVANT.value,
9
+ EmissionMethodTier.TIER_1.value,
10
+ EmissionMethodTier.TIER_2.value,
11
+ EmissionMethodTier.TIER_3.value,
12
+ EmissionMethodTier.MEASURED.value,
13
+ EmissionMethodTier.BACKGROUND.value
14
+ ]
15
+
16
+
17
+ def _has_threshold_diff(source: dict, dest: dict, key: str, threshold: float):
18
+ term_id = dest.get('term', {}).get('@id', dest.get('@id'))
19
+ source_value = _average(source.get(key), None)
20
+ dest_value = _average(dest.get(key), None)
21
+ delta = None if any([source_value is None, dest_value is None]) else (
22
+ abs(source_value - dest_value) / (1 if source_value == 0 else source_value)
23
+ )
24
+ should_merge = source_value is None or (delta is not None and delta > threshold)
25
+ logger.debug('merge %s for %s with threshold=%s, delta=%s: %s', key, term_id, threshold, delta, should_merge)
26
+ return should_merge
27
+
28
+
29
+ def _should_merge_threshold(source: dict, dest: dict, args: dict):
30
+ [key, threshold] = args.get('replaceThreshold', [None, 0])
31
+ return True if key is None else _has_threshold_diff(source, dest, key, threshold)
32
+
33
+
34
+ def _should_merge_lower_tier(source: dict, dest: dict, args: dict):
35
+ source_tier = _METHOD_TIER_ORDER.index(source.get('methodTier', _METHOD_TIER_ORDER[0]))
36
+ dest_tier = _METHOD_TIER_ORDER.index(dest.get('methodTier', _METHOD_TIER_ORDER[-1]))
37
+ term_id = dest.get('term', {}).get('@id', dest.get('@id'))
38
+ should_merge = args.get('replaceLowerTier', False) or dest_tier >= source_tier
39
+ logger.debug('merge for %s with original tier=%s, new tier=%s: %s',
40
+ term_id, source.get('methodTier'), dest.get('methodTier'), should_merge)
41
+ return should_merge
42
+
43
+
44
+ _MERGE_FROM_ARGS = {
45
+ 'replaceThreshold': _should_merge_threshold,
46
+ 'replaceLowerTier': _should_merge_lower_tier
47
+ }
48
+
49
+
50
+ def merge(source: dict, dest: dict, version: str, model: dict = {}, merge_args: dict = {}, *args):
51
+ merge_args = {
52
+ key: func(source, dest, merge_args) for key, func in _MERGE_FROM_ARGS.items()
53
+ } if source is not None else {}
54
+ term_id = dest.get('term', {}).get('@id', dest.get('@id'))
55
+
56
+ should_merge = all([v for _k, v in merge_args.items()])
57
+ logShouldMerge(source, model.get('model'), term_id, should_merge, key=model.get('key'), value=term_id, **merge_args)
58
+
59
+ return update_node_version(version, pydash.objects.merge({}, source, dest), source) if should_merge else source
@@ -0,0 +1,8 @@
1
+ import importlib
2
+
3
+ from hestia_earth.orchestrator.utils import get_required_model_param
4
+
5
+
6
+ def should_run(data: dict, model: dict):
7
+ strategy = get_required_model_param(model, 'runStrategy')
8
+ return importlib.import_module(f"hestia_earth.orchestrator.strategies.run.{strategy}").should_run(data, model)