Skip to content

Commit 7f38686

Browse files
Merge pull request #21 from EcoExtreML/add_model_class
Add model class
2 parents a3dc480 + a5c4bd8 commit 7f38686

103 files changed

Lines changed: 622 additions & 281 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

PyStemmusScope/__init__.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
"""Documentation about PyStemmusScope"""
22
import logging
3-
from . import forcing_io
4-
from . import iostreamer
5-
from .iostreamer import create_io_dir
6-
from .iostreamer import read_config
3+
from .stemmus_scope import StemmusScope
74

85

96
logging.getLogger(__name__).addHandler(logging.NullHandler())
Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import os
88
import shutil
99
import time
10-
from pathlib import Path
10+
from . import utils
1111

1212

1313
logger = logging.getLogger(__name__)
@@ -42,27 +42,28 @@ def create_io_dir(forcing_filename, config):
4242
# get start time with the format Y-M-D-HM
4343
timestamp = time.strftime('%Y-%m-%d-%H%M')
4444
station_name = forcing_filename.split('_')[0]
45+
4546
# create input directory
46-
input_dir = Path(f"{config['WorkDir']}/input/{station_name}_{timestamp}")
47-
Path(input_dir).mkdir(parents=True, exist_ok=True)
47+
work_dir = utils.to_absolute_path(config['WorkDir'])
48+
input_dir = work_dir / "input" / f"{station_name}_{timestamp}"
49+
input_dir.mkdir(parents=True, exist_ok=True)
4850
message = f"Prepare work directory {input_dir} for the station: {station_name}"
4951
logger.info("%s", message)
52+
5053
# copy model parameters to work directory
5154
_copy_data(input_dir, config)
52-
input_dir = str(input_dir)
5355

5456
# create output directory
55-
output_dir = Path(f"{config['WorkDir']}/output/{station_name}_{timestamp}")
57+
output_dir = work_dir / "output" / f"{station_name}_{timestamp}"
5658
output_dir.mkdir(parents=True, exist_ok=True)
5759
message = f"Prepare work directory {output_dir} for the station: {station_name}"
5860
logger.info("%s", message)
59-
output_dir = str(output_dir)
6061

6162
# update config file for ForcingFileName and InputPath
6263
config_file_path = _update_config_file(forcing_filename, input_dir, output_dir,
6364
config, station_name, timestamp)
6465

65-
return input_dir, output_dir, config_file_path
66+
return str(input_dir), str(output_dir), config_file_path
6667

6768
def _copy_data(input_dir, config):
6869
"""Copy required data to the work directory.
@@ -98,7 +99,7 @@ def _update_config_file(nc_file, input_dir, output_dir, config, station_name, ti
9899
Returns:
99100
Path to updated config file.
100101
"""
101-
config_file_path = Path(input_dir, f"{station_name}_{timestamp}_config.txt")
102+
config_file_path = input_dir / f"{station_name}_{timestamp}_config.txt"
102103
with open(config_file_path, 'w', encoding="utf8") as f:
103104
for key, value in config.items():
104105
if key == "ForcingFileName":

PyStemmusScope/forcing_io.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,20 +156,18 @@ def prepare_global_variables(data, input_path, config):
156156
hdf5storage.savemat(input_path / 'forcing_globals.mat', matfiledata, appendmat=False)
157157

158158

159-
def prepare_forcing(input_dir, forcing_file, config):
159+
def prepare_forcing(config):
160160
"""Function to prepare the forcing files required by STEMMUS_SCOPE. The input
161161
directory should be taken from the model configuration file.
162162
163163
Args:
164-
input_dir (path or str): Path to the input directory that will be read by
165-
STEMMUS_SCOPE.
166-
forcing_file (path or str): Path to the netCDF forcing file that will be used
167-
to generate the input data.
168164
config (dict): The PyStemmusScope configuration dictionary.
169165
"""
170-
input_path = Path(input_dir)
166+
167+
input_path = Path(config["InputPath"])
171168

172169
# Read the required data from the forcing file into a dictionary
170+
forcing_file = Path(config["ForcingPath"]) / config["ForcingFileName"]
173171
data = read_forcing_data(forcing_file)
174172

175173
# Write the single-column ascii '.dat' files to the input directory

PyStemmusScope/soil_io.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -205,28 +205,25 @@ def _retrieve_latlon(file):
205205
return lat, lon
206206

207207

208-
def prepare_soil_data(soil_data_dir, matfile_path, run_config):
208+
def prepare_soil_data(config):
209209
"""Function that prepares the soil input data for the STEMMUS_SCOPE model. It parses
210210
the data for the input location, and writes a file that can be easily read in by
211211
Matlab.
212212
213213
Args:
214-
soil_data_dir (Path): Path to the directory which contains the soil data.
215-
mathfile_path (Path): Path to the directory where soil parameter file
216-
should be written to.
217-
run_config (dict): Dictionary containing the configuration for the current
218-
STEMMUS_SCOPE run.
214+
config (dict): The PyStemmusScope configuration dictionary.
219215
"""
220-
forcing_file = Path(run_config["ForcingPath"]) / run_config["ForcingFileName"]
216+
217+
forcing_file = Path(config["ForcingPath"]) / config["ForcingFileName"]
221218

222219
# Data missing at ID-Pag site. See github.com/EcoExtreML/STEMMUS_SCOPE/issues/77
223-
if run_config["ForcingFileName"].startswith("ID"):
220+
if config["ForcingFileName"].startswith("ID"):
224221
lat, lon = -1., 112.
225222
else:
226223
lat, lon = _retrieve_latlon(forcing_file)
227224

228-
matfiledata = _collect_soil_data(Path(soil_data_dir), lat, lon)
225+
matfiledata = _collect_soil_data(Path(config['SoilPropertyPath']), lat, lon)
229226

230227
hdf5storage.savemat(
231-
Path(matfile_path) / "soil_parameters.mat", mdict=matfiledata, appendmat=False,
228+
Path(config["InputPath"]) / "soil_parameters.mat", mdict=matfiledata, appendmat=False,
232229
)

PyStemmusScope/stemmus_scope.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""PyStemmusScope wrapper around Stemmus_Scope."""
2+
3+
import logging
4+
import os
5+
import subprocess
6+
from typing import Dict
7+
from . import config_io
8+
from . import forcing_io
9+
from . import soil_io
10+
from . import utils
11+
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
class StemmusScope():
17+
"""PyStemmusScope wrapper around Stemmus_Scope model.
18+
see https://gmd.copernicus.org/articles/14/1379/2021/
19+
20+
It sets the model with a configuration file and executable file.
21+
It also prepares forcing and soil data for model run.
22+
23+
Args:
24+
config_file(str): path to Stemmus_Scope configuration file. An example
25+
config_file can be found in tests/test_data in `STEMMUS_SCOPE_Processing
26+
repository <https://github.com/EcoExtreML/STEMMUS_SCOPE_Processing>`_
27+
exe_file(str): path to Stemmus_Scope executable file.
28+
29+
Example:
30+
See notebooks/run_model_in_notebook.ipynb in `STEMMUS_SCOPE_Processing
31+
repository <https://github.com/EcoExtreML/STEMMUS_SCOPE_Processing>`_
32+
"""
33+
34+
def __init__(self, config_file: str, exe_file: str):
35+
# make sure paths are abolute and path objects
36+
config_file = utils.to_absolute_path(config_file)
37+
self.exe_file = utils.to_absolute_path(exe_file)
38+
39+
# read config template
40+
self._configs = config_io.read_config(config_file)
41+
42+
def setup(
43+
self,
44+
WorkDir: str = None,
45+
ForcingFileName: str = None,
46+
NumberOfTimeSteps: str = None,
47+
) -> str:
48+
"""Configure model run.
49+
50+
1. Creates config file and input/output directories based on the config template.
51+
2. Prepare forcing and soil data
52+
53+
Args:
54+
WorkDir: path to a directory where input/output directories should be created.
55+
ForcingFileName: forcing file name. Forcing file should be in netcdf format.
56+
NumberOfTimeSteps: total number of time steps in which model runs. It can be
57+
`NA` or a number. Example `10` runs the model for 10 time steps.
58+
59+
Returns:
60+
Paths to config file and input/output directories
61+
"""
62+
# update config template if needed
63+
if WorkDir:
64+
self._configs["WorkDir"] = WorkDir
65+
66+
if ForcingFileName:
67+
self._configs["ForcingFileName"] = ForcingFileName
68+
69+
if NumberOfTimeSteps:
70+
self._configs["NumberOfTimeSteps"] = NumberOfTimeSteps
71+
72+
# create customized config file and input/output directories for model run
73+
_, _, self.cfg_file = config_io.create_io_dir(
74+
self._configs["ForcingFileName"], self._configs
75+
)
76+
77+
# read the run config file
78+
self._configs = config_io.read_config(self.cfg_file)
79+
80+
# prepare forcing data
81+
forcing_io.prepare_forcing(self._configs)
82+
83+
# prepare soil data
84+
soil_io.prepare_soil_data(self._configs)
85+
86+
# set matlab log dir
87+
os.environ['MATLAB_LOG_DIR'] = str(self._configs["InputPath"])
88+
89+
return str(self.cfg_file)
90+
91+
def run(self) -> str:
92+
"""Run model using executable.
93+
94+
Args:
95+
96+
Returns:
97+
Tuple with stdout and stderr
98+
"""
99+
100+
# run the model
101+
args = [f"{self.exe_file} {self.cfg_file}"]
102+
result = subprocess.run(
103+
args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, check=True,
104+
)
105+
stdout = result.stdout
106+
107+
# TODO return log info line by line!
108+
logger.info("%s", stdout)
109+
110+
return stdout
111+
112+
113+
@property
114+
def config(self) -> Dict:
115+
"""Return the configurations for this model."""
116+
return self._configs

PyStemmusScope/utils.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os
2+
from pathlib import Path
13
import numpy as np
24

35

@@ -22,3 +24,46 @@ def convert_to_lsm_coordinates(lat, lon):
2224
lon += 720
2325

2426
return np.round(lat).astype(int), np.round(lon).astype(int)
27+
28+
29+
def os_name():
30+
return os.name
31+
32+
33+
def to_absolute_path(
34+
input_path: str,
35+
parent: Path = None,
36+
must_be_in_parent=True,
37+
) -> Path:
38+
"""Parse input string as :py:class:`pathlib.Path` object.
39+
40+
Args:
41+
input_path: Input string path that can be a relative or absolute path.
42+
parent: Optional parent path of the input path
43+
must_exist: Optional argument to check if the input path exists.
44+
must_be_in_parent: Optional argument to check if the input path is
45+
subpath of parent path
46+
47+
Returns:
48+
The input path that is an absolute path and a :py:class:`pathlib.Path` object.
49+
"""
50+
51+
must_exist = False
52+
pathlike = Path(input_path)
53+
if parent:
54+
if not parent.is_absolute():
55+
# care for windows, see issue 22
56+
must_exist = os_name() == 'nt'
57+
pathlike = parent.joinpath(pathlike)
58+
if must_be_in_parent:
59+
try:
60+
pathlike.relative_to(parent)
61+
except ValueError as exc:
62+
raise ValueError(
63+
f"Input path {input_path} is not a subpath of parent {parent}"
64+
) from exc
65+
else:
66+
# care for windows, see issue 22
67+
must_exist = os_name() == 'nt'
68+
69+
return pathlike.expanduser().resolve(strict=must_exist)

0 commit comments

Comments
 (0)