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.
- hestia_earth/models/cml2001Baseline/abioticResourceDepletionFossilFuels.py +9 -5
- 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/hestia/landCover.py +70 -22
- hestia_earth/models/ipcc2019/animal/hoursWorkedPerDay.py +38 -0
- hestia_earth/models/mocking/search-results.json +833 -829
- hestia_earth/models/site/management.py +82 -22
- hestia_earth/models/utils/crop.py +5 -1
- 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.5.dist-info → hestia_earth_models-0.65.7.dist-info}/METADATA +27 -5
- {hestia_earth_models-0.65.5.dist-info → hestia_earth_models-0.65.7.dist-info}/RECORD +57 -13
- tests/models/cml2001Baseline/test_abioticResourceDepletionFossilFuels.py +1 -1
- tests/models/hestia/test_landCover.py +2 -1
- 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.5.dist-info → hestia_earth_models-0.65.7.dist-info}/LICENSE +0 -0
- {hestia_earth_models-0.65.5.dist-info → hestia_earth_models-0.65.7.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
181
|
-
_include(cycle, ["startDate", "endDate"]) |
|
|
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':
|
|
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
|
|
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
|
-
|
|
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
|
|
302
|
+
return land_cover_products + _run_products(
|
|
258
303
|
cycle,
|
|
259
304
|
crop_forage_products,
|
|
260
|
-
total_products=len(crop_forage_products) + len(
|
|
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 =
|
|
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
|
|
277
|
-
products_with_gap_filled_props = [p for p in products if p
|
|
278
|
-
products_without_gap_filled_props = [p for p in products if not p
|
|
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
|
|
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
|
+
)
|
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
|