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.
- hestia_earth/models/config/Cycle.json +2193 -0
- hestia_earth/models/config/ImpactAssessment.json +2041 -0
- hestia_earth/models/config/Site.json +471 -0
- hestia_earth/models/config/__init__.py +71 -0
- hestia_earth/models/config/run-calculations.json +42 -0
- hestia_earth/models/config/trigger-calculations.json +43 -0
- hestia_earth/models/ipcc2019/animal/hoursWorkedPerDay.py +38 -0
- hestia_earth/models/mocking/search-results.json +4524 -27
- hestia_earth/models/version.py +1 -1
- hestia_earth/orchestrator/__init__.py +40 -0
- hestia_earth/orchestrator/log.py +62 -0
- hestia_earth/orchestrator/models/__init__.py +118 -0
- hestia_earth/orchestrator/models/emissions/__init__.py +0 -0
- hestia_earth/orchestrator/models/emissions/deleted.py +15 -0
- hestia_earth/orchestrator/models/transformations.py +103 -0
- hestia_earth/orchestrator/strategies/__init__.py +0 -0
- hestia_earth/orchestrator/strategies/merge/__init__.py +42 -0
- hestia_earth/orchestrator/strategies/merge/merge_append.py +29 -0
- hestia_earth/orchestrator/strategies/merge/merge_default.py +1 -0
- hestia_earth/orchestrator/strategies/merge/merge_list.py +103 -0
- hestia_earth/orchestrator/strategies/merge/merge_node.py +59 -0
- hestia_earth/orchestrator/strategies/run/__init__.py +8 -0
- hestia_earth/orchestrator/strategies/run/add_blank_node_if_missing.py +85 -0
- hestia_earth/orchestrator/strategies/run/add_key_if_missing.py +9 -0
- hestia_earth/orchestrator/strategies/run/always.py +6 -0
- hestia_earth/orchestrator/utils.py +116 -0
- {hestia_earth_models-0.65.6.dist-info → hestia_earth_models-0.65.7.dist-info}/METADATA +27 -5
- {hestia_earth_models-0.65.6.dist-info → hestia_earth_models-0.65.7.dist-info}/RECORD +51 -7
- tests/models/ipcc2019/animal/test_hoursWorkedPerDay.py +22 -0
- tests/models/test_config.py +115 -0
- tests/orchestrator/__init__.py +0 -0
- tests/orchestrator/models/__init__.py +0 -0
- tests/orchestrator/models/emissions/__init__.py +0 -0
- tests/orchestrator/models/emissions/test_deleted.py +21 -0
- tests/orchestrator/models/test_transformations.py +29 -0
- tests/orchestrator/strategies/__init__.py +0 -0
- tests/orchestrator/strategies/merge/__init__.py +0 -0
- tests/orchestrator/strategies/merge/test_merge_append.py +33 -0
- tests/orchestrator/strategies/merge/test_merge_default.py +7 -0
- tests/orchestrator/strategies/merge/test_merge_list.py +327 -0
- tests/orchestrator/strategies/merge/test_merge_node.py +95 -0
- tests/orchestrator/strategies/run/__init__.py +0 -0
- tests/orchestrator/strategies/run/test_add_blank_node_if_missing.py +114 -0
- tests/orchestrator/strategies/run/test_add_key_if_missing.py +14 -0
- tests/orchestrator/strategies/run/test_always.py +5 -0
- tests/orchestrator/test_models.py +69 -0
- tests/orchestrator/test_orchestrator.py +27 -0
- tests/orchestrator/test_utils.py +109 -0
- {hestia_earth_models-0.65.6.dist-info → hestia_earth_models-0.65.7.dist-info}/LICENSE +0 -0
- {hestia_earth_models-0.65.6.dist-info → hestia_earth_models-0.65.7.dist-info}/WHEEL +0 -0
- {hestia_earth_models-0.65.6.dist-info → hestia_earth_models-0.65.7.dist-info}/top_level.txt +0 -0
hestia_earth/models/version.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
VERSION = '0.65.
|
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)
|