Skip to content

Commit 84cec34

Browse files
LucR31yakutovicha
andauthored
Add ifs plugin (#16)
* Add ifs plugin * Apply suggestions from code review Co-authored-by: Aliaksandr Yakutovich <[email protected]> * apply requested changes --------- Co-authored-by: Aliaksandr Yakutovich <[email protected]>
1 parent 6a6725f commit 84cec34

File tree

7 files changed

+611
-0
lines changed

7 files changed

+611
-0
lines changed
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Calculations provided by aiida_flexpart.
4+
5+
Register calculations via the "aiida.calculations" entry point in setup.json.
6+
"""
7+
import os
8+
import importlib
9+
import datetime
10+
import pathlib
11+
import jinja2
12+
13+
from aiida import common, orm, engine
14+
from ..utils import fill_in_template_file
15+
16+
17+
class FlexpartIfsCalculation(engine.CalcJob):
18+
"""AiiDA calculation plugin wrapping the FLEXPART IFS executable."""
19+
@classmethod
20+
def define(cls, spec):
21+
"""Define inputs and outputs of the calculation."""
22+
# yapf: disable
23+
super().define(spec)
24+
25+
# set default values for AiiDA options
26+
spec.inputs['metadata']['options']['resources'].default = {
27+
'num_machines': 1,
28+
'num_mpiprocs_per_machine': 1,
29+
}
30+
31+
spec.input('metadata.options.max_wallclock_seconds', valid_type=int, default=1800)
32+
spec.input('metadata.options.parser_name', valid_type=str, default='flexpart.ifs')
33+
34+
spec.input(
35+
'parent_calc_folder',
36+
valid_type=orm.RemoteData,
37+
required=False,
38+
help='Working directory of a previously ran calculation to restart from.'
39+
)
40+
41+
# Model settings
42+
spec.input_namespace('model_settings')
43+
spec.input('model_settings.release_settings', valid_type=orm.Dict, required=True)
44+
spec.input('model_settings.locations', valid_type=orm.Dict, required=True)
45+
spec.input('model_settings.command', valid_type=orm.Dict, required=True)
46+
47+
spec.input('outgrid', valid_type=orm.Dict, help='Input file for the Lagrangian particle dispersion model FLEXPART.')
48+
spec.input('outgrid_nest', valid_type=orm.Dict, required=False,
49+
help='Input file for the Lagrangian particle dispersion model FLEXPART. Nested output grid.'
50+
)
51+
spec.input('species', valid_type=orm.RemoteData, required=True)
52+
spec.input_namespace('land_use', valid_type=orm.RemoteData, required=False, dynamic=True, help='#TODO')
53+
54+
spec.input('meteo_path', valid_type=orm.List,
55+
required=True, help='Path to the folder containing the meteorological input data.')
56+
spec.input('metadata.options.output_filename', valid_type=str, default='aiida.out', required=True)
57+
spec.outputs.dynamic = True
58+
59+
#exit codes
60+
spec.exit_code(300, 'ERROR_MISSING_OUTPUT_FILES', message='Calculation did not produce all expected output files.')
61+
62+
@classmethod
63+
def _deal_with_time(cls, command_dict):
64+
"""Dealing with simulation times."""
65+
#initial values
66+
simulation_beginning_date = datetime.datetime.strptime(command_dict.pop('simulation_date'),'%Y-%m-%d %H:%M:%S')
67+
age_class_time = datetime.timedelta(seconds=command_dict.pop('age_class'))
68+
release_chunk = datetime.timedelta(seconds=command_dict.pop('release_chunk'))
69+
release_duration = datetime.timedelta(seconds=command_dict.pop('release_duration'))
70+
71+
#releases start and end times
72+
release_beginning_date=simulation_beginning_date
73+
release_ending_date=release_beginning_date+release_duration
74+
75+
if command_dict['simulation_direction']>0: #forward
76+
simulation_ending_date=release_ending_date+age_class_time
77+
else: #backward
78+
simulation_ending_date=release_ending_date
79+
simulation_beginning_date-=age_class_time
80+
81+
command_dict['simulation_beginning_date'] = [
82+
f'{simulation_beginning_date:%Y%m%d}',
83+
f'{simulation_beginning_date:%H%M%S}'
84+
]
85+
command_dict['simulation_ending_date'] = [
86+
f'{simulation_ending_date:%Y%m%d}',
87+
f'{simulation_ending_date:%H%M%S}'
88+
]
89+
return {
90+
'beginning_date': release_beginning_date,
91+
'ending_date': release_ending_date,
92+
'chunk': release_chunk
93+
} , age_class_time
94+
95+
def prepare_for_submission(self, folder):
96+
97+
meteo_string_list = ['./','./']
98+
for path in self.inputs.meteo_path:
99+
meteo_string_list.append(f'{path}{os.sep}')
100+
meteo_string_list.append(f'{path}/AVAILABLE')
101+
102+
codeinfo = common.CodeInfo()
103+
codeinfo.cmdline_params = meteo_string_list
104+
codeinfo.code_uuid = self.inputs.code.uuid
105+
codeinfo.stdout_name = self.metadata.options.output_filename
106+
codeinfo.withmpi = self.inputs.metadata.options.withmpi
107+
108+
# Prepare a `CalcInfo` to be returned to the engine
109+
calcinfo = common.CalcInfo()
110+
calcinfo.codes_info = [codeinfo]
111+
112+
113+
command_dict = self.inputs.model_settings.command.get_dict()
114+
115+
# Deal with simulation times.
116+
release, age_class_time = self._deal_with_time(command_dict)
117+
118+
# Fill in the releases file.
119+
with folder.open('RELEASES', 'w') as infile:
120+
time_chunks = []
121+
current_time = release['beginning_date'] + release['chunk']
122+
while current_time <= release['ending_date']:
123+
time_chunks.append({
124+
'begin': [f'{current_time-release["chunk"]:%Y%m%d}', f'{current_time-release["chunk"]:%H%M%S}'],
125+
'end': [f'{current_time:%Y%m%d}', f'{current_time:%H%M%S}'],
126+
})
127+
current_time += release['chunk']
128+
129+
template = jinja2.Template(importlib.resources.read_text('aiida_flexpart.templates', 'RELEASES.j2'))
130+
infile.write(template.render(
131+
time_chunks=time_chunks,
132+
locations=self.inputs.model_settings.locations.get_dict(),
133+
release_settings=self.inputs.model_settings.release_settings.get_dict()
134+
)
135+
)
136+
137+
# Fill in the AGECLASSES file.
138+
fill_in_template_file(folder, 'AGECLASSES', int(age_class_time.total_seconds()))
139+
140+
# Fill in the OUTGRID_NEST file if the corresponding dictionary is present.
141+
if 'outgrid_nest' in self.inputs:
142+
command_dict['nested_output'] = True
143+
fill_in_template_file(folder, 'OUTGRID_NEST_ifs', self.inputs.outgrid_nest.get_dict())
144+
else:
145+
command_dict['nested_output'] = False
146+
147+
# Fill in the COMMAND file.
148+
fill_in_template_file(folder, 'COMMAND_ifs', command_dict)
149+
150+
# Fill in the OUTGRID file.
151+
fill_in_template_file(folder, 'OUTGRID_ifs', self.inputs.outgrid.get_dict())
152+
153+
154+
calcinfo.remote_symlink_list = []
155+
calcinfo.remote_symlink_list.append((
156+
self.inputs.species.computer.uuid,
157+
self.inputs.species.get_remote_path(),
158+
'SPECIES'
159+
))
160+
161+
if 'parent_calc_folder' in self.inputs:
162+
computer_uuid = self.inputs.parent_calc_folder.computer.uuid
163+
remote_path = self.inputs.parent_calc_folder.get_remote_path()
164+
calcinfo.remote_symlink_list.append((
165+
computer_uuid,
166+
remote_path+'/header',
167+
'header_previous'))
168+
calcinfo.remote_symlink_list.append((
169+
computer_uuid,
170+
remote_path+'/partposit_inst',
171+
'partposit_previous'))
172+
173+
174+
# Dealing with land_use input namespace.
175+
for _, value in self.inputs.land_use.items():
176+
file_path = value.get_remote_path()
177+
calcinfo.remote_symlink_list.append((value.computer.uuid, file_path, pathlib.Path(file_path).name))
178+
179+
calcinfo.retrieve_list = ['grid_time_*.nc', 'aiida.out']
180+
181+
return calcinfo
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Parsers provided by aiida_flexpart.
4+
5+
Register parsers via the "aiida.parsers" entry point in setup.json.
6+
"""
7+
from aiida import parsers, plugins, common, orm, engine
8+
9+
FlexpartCalculation = plugins.CalculationFactory('flexpart.ifs')
10+
11+
12+
class FlexpartIfsParser(parsers.Parser):
13+
"""
14+
Parser class for parsing output of calculation.
15+
"""
16+
def __init__(self, node):
17+
"""
18+
Initialize Parser instance
19+
20+
Checks that the ProcessNode being passed was produced by a FlexpartCalculation.
21+
22+
:param node: ProcessNode of calculation
23+
:param type node: :class:`aiida.orm.ProcessNode`
24+
"""
25+
super().__init__(node)
26+
if not issubclass(node.process_class, FlexpartCalculation):
27+
raise common.ParsingError('Can only parse FlexpartCalculation')
28+
29+
def parse(self, **kwargs):
30+
"""
31+
Parse outputs, store results in database.
32+
33+
:returns: an exit code, if parsing fails (or nothing if parsing succeeds)
34+
"""
35+
output_filename = self.node.get_option('output_filename')
36+
37+
# Check that folder content is as expected
38+
files_retrieved = self.retrieved.list_object_names()
39+
files_expected = [output_filename]
40+
# Note: set(A) <= set(B) checks whether A is a subset of B
41+
if not set(files_expected) <= set(files_retrieved):
42+
self.logger.error(
43+
f"Found files '{files_retrieved}', expected to find '{files_expected}'"
44+
)
45+
return self.exit_codes.ERROR_MISSING_OUTPUT_FILES
46+
47+
# check aiida.out content
48+
with self.retrieved.open(output_filename, 'r') as handle:
49+
content = handle.read()
50+
output_node = orm.SinglefileData(file=handle)
51+
if 'CONGRATULATIONS' not in content:
52+
self.out('output_file', output_node)
53+
return engine.ExitCode(1)
54+
# add output file
55+
self.logger.info(f"Parsing '{output_filename}'")
56+
with self.retrieved.open(output_filename, 'rb') as handle:
57+
output_node = orm.SinglefileData(file=handle)
58+
self.out('output_file', output_node)
59+
60+
return engine.ExitCode(0)

0 commit comments

Comments
 (0)