Skip to content

Commit 378774e

Browse files
Merge pull request #13 from EcoExtreML/read_forcing
Added read routines for the matlab forcing data
2 parents c4194a7 + 901c713 commit 378774e

23 files changed

Lines changed: 818 additions & 20 deletions

PyStemmusScope/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Documentation about PyStemmusScope"""
22
import logging
3+
from . import forcing_io
4+
from . import iostreamer
35
from .iostreamer import create_io_dir
46
from .iostreamer import read_config
57

PyStemmusScope/forcing_io.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
from pathlib import Path
2+
import hdf5storage
3+
import numpy as np
4+
import xarray as xr
5+
from . import variable_conversion as vc
6+
7+
8+
def _write_matlab_ascii(fname, data, ncols):
9+
"""Internal function to handle writing data in the Matlab ascii format. Equivalent
10+
to `save([-], '-ascii')` in Matlab.
11+
12+
Args:
13+
fname (path or str): Path to the file that should be written
14+
data (np.array): Array with data to write to file
15+
ncols (int, optional): The number of data columns, required to correctly format
16+
the ascii file when writing multiple variables.
17+
"""
18+
matlab_fmt = ' %14.7e'
19+
multi_fmt = [matlab_fmt]*ncols
20+
multi_fmt[0] = f' {multi_fmt[0]}'
21+
np.savetxt(fname, data, multi_fmt)
22+
23+
24+
def read_forcing_data(forcing_file):
25+
"""Reads the forcing data from the provided netCDF file, and applies the required
26+
unit conversions before returning the read data.
27+
28+
Args:
29+
forcing_file (Path): Path to the netCDF file containing the forcing data
30+
31+
Returns:
32+
dict: Dictionary containing the different variables required by STEMMUS_SCOPE
33+
for the different forcing files.
34+
"""
35+
ds_forcing = xr.open_dataset(forcing_file)
36+
37+
# remove the x and y coordinates from the data variables to make the numpy arrays 1D
38+
ds_forcing = ds_forcing.squeeze(['x', 'y'])
39+
40+
data = {}
41+
42+
# Expected time format is days (as floating point) since Jan 1st 00:00.
43+
data['doy_float'] = (
44+
ds_forcing['time'].dt.dayofyear - 1 +
45+
ds_forcing['time'].dt.hour/24 +
46+
ds_forcing['time'].dt.minute/60/24
47+
)
48+
data['year'] = ds_forcing['time'].dt.year.astype(float)
49+
50+
data['t_air_celcius'] = vc.kelvin_to_celcius(ds_forcing['Tair'])
51+
data['psurf_hpa'] = vc.pa_to_hpa(ds_forcing['Psurf'])
52+
data['co2_conv'] = vc.co2_molar_fraction_to_kg_per_m3(ds_forcing['CO2air'])
53+
data['precip_conv'] = vc.precipitation_mm_s_to_cm_s(ds_forcing['Precip'])
54+
data['lw_down'] = ds_forcing['LWdown']
55+
data['sw_down'] = ds_forcing['SWdown']
56+
data['wind_speed'] = ds_forcing['Wind']
57+
data['rh'] = ds_forcing['RH']
58+
data['vpd'] = ds_forcing['VPD']
59+
data['lai'] = vc.mask_data(ds_forcing['LAI'], min_value=0.01)
60+
data['ea'] = vc.kpa_to_hpa(vc.calculate_ea(data['t_air_celcius'], data['rh']))
61+
62+
# Load in non-timedependent variables
63+
data['sitename'] = forcing_file.name.split('_')[0]
64+
# Forcing data timestep size in seconds
65+
time_delta = (ds_forcing.time.values[1] -
66+
ds_forcing.time.values[0]) / np.timedelta64(1, 's')
67+
data['DELT'] = time_delta.astype(float)
68+
data['total_timesteps'] = ds_forcing.time.size
69+
70+
data['latitude'] = ds_forcing['latitude'].values
71+
data['longitude'] = ds_forcing['longitude'].values
72+
data['elevation'] = ds_forcing['elevation'].values
73+
data['IGBP_veg_long'] = ds_forcing['IGBP_veg_long'].values
74+
data['reference_height'] = ds_forcing['reference_height'].values
75+
data['canopy_height'] = ds_forcing['canopy_height'].values
76+
77+
return data
78+
79+
80+
def write_dat_files(data, input_dir):
81+
"""Fuction to write the single-data .dat files for the STEMMUS_SCOPE matlab model.
82+
83+
Args:
84+
data (dict): Data dictionary generated by read_forcing
85+
input_dir (path or str): Directory to which the different single-column .dat
86+
files should be written to.
87+
"""
88+
write_info = {
89+
'doy_float': 't_.dat',
90+
't_air_celcius': 'Ta_.dat',
91+
'sw_down': 'Rin_.dat',
92+
'lw_down': 'Rli_.dat',
93+
'psurf_hpa': 'p_.dat',
94+
'wind_speed': 'u_.dat',
95+
'co2_conv': 'CO2_.dat',
96+
'ea': 'ea_.dat',
97+
'year': 'year_.dat'
98+
}
99+
for var, fname in write_info.items():
100+
fpath = Path(input_dir) / fname
101+
_write_matlab_ascii(fpath, data[var], ncols=1)
102+
103+
104+
def write_lai_file(data, fname):
105+
"""Function to write the ascii LAI_.dat file for STEMMUS_SCOPE.
106+
107+
Args:
108+
data (dict): Dictionary containing the required variables. Generated by the
109+
function `read_forcing_data`.
110+
fname (Path): Full path, including filename, to which the file should be
111+
written to.
112+
"""
113+
lai_file_data = np.vstack([data['doy_float'], data['lai']]).T
114+
_write_matlab_ascii(fname, lai_file_data, ncols=2)
115+
116+
117+
def write_meteo_file(data, fname):
118+
"""Function to write the ascii Mdata.txt meteo file for STEMMUS_SCOPE.
119+
120+
Args:
121+
data (dict): Dictionary containing the required variables. Generated by the
122+
function `read_forcing_data`.
123+
fname (Path): Full path, including filename, to which the file should be
124+
written to.
125+
"""
126+
meteo_data_vars = ['doy_float', 't_air_celcius', 'rh',
127+
'wind_speed', 'psurf_hpa', 'precip_conv', 'sw_down',
128+
'lw_down', 'vpd', 'lai']
129+
meteo_file_data = np.vstack([data[var] for var in meteo_data_vars]).T
130+
_write_matlab_ascii(fname, meteo_file_data, ncols=len(meteo_data_vars))
131+
132+
133+
def prepare_global_variables(data, input_path, config):
134+
"""Function to read and calculate global variables for STEMMUS_SCOPE from the
135+
forcing data. Data will be written to a Matlab binary file (v7.3), under the name
136+
'forcing_globals.mat' in the specified input directory.
137+
138+
Args:
139+
data (dict): Dictionary containing the required variables. Generated by the
140+
function `read_forcing_data`.
141+
input_path (Path): Path to which the file should be written to.
142+
config (dict): The PyStemmusScope configuration dictionary.
143+
"""
144+
if int(config['NumberOfTimeSteps']) > data['total_timesteps']:
145+
total_duration = data['total_timesteps']
146+
else:
147+
total_duration = int(config['NumberOfTimeSteps'])
148+
149+
matfile_vars = ['latitude', 'longitude', 'elevation', 'IGBP_veg_long',
150+
'reference_height', 'canopy_height', 'DELT', 'sitename']
151+
matfiledata = {key: data[key] for key in matfile_vars}
152+
153+
matfiledata['Dur_tot'] = float(total_duration) # Matlab expects a 'double'
154+
155+
hdf5storage.savemat(input_path / 'forcing_globals.mat', matfiledata, appendmat=False)
156+
157+
158+
def prepare_forcing(input_dir, forcing_file, config):
159+
"""Function to prepare the forcing files required by STEMMUS_SCOPE. The input
160+
directory should be taken from the model configuration file.
161+
162+
Args:
163+
input_dir (path or str): Path to the input directory that will be read by
164+
STEMMUS_SCOPE.
165+
forcing_file (path or str): Path to the netCDF forcing file that will be used
166+
to generate the input data.
167+
config (dict): The PyStemmusScope configuration dictionary.
168+
"""
169+
input_path = Path(input_dir)
170+
171+
# Read the required data from the forcing file into a dictionary
172+
data = read_forcing_data(forcing_file)
173+
174+
# Write the single-column ascii '.dat' files to the input directory
175+
write_dat_files(data, input_path)
176+
177+
# Write the two-column LAI_.dat file to the input directory.
178+
write_lai_file(data, input_path / 'LAI_.dat')
179+
180+
# Write the multi-column Mdata.txt ascii file to the input directory
181+
write_meteo_file(data, input_path / 'Mdata.txt')
182+
183+
# Write the remaining variables (without time dependency) to the matlab v7.3
184+
# file 'forcing_globals.mat'
185+
prepare_global_variables(data, input_path, config)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import numpy as np
2+
3+
4+
def calculate_ea(t_air_celcius, rh):
5+
"""Function that calculates the actual vapour pressure (kPa) from the
6+
air temperature (degree Celcius) and relative humidity (%)
7+
8+
Args:
9+
t_air_celcius: numpy array containing the air temperature in degrees C.
10+
11+
rh: numpy array of same shape as t_air_celcius, containing the relative humidity
12+
as a percentage (e.g. ranging from 0 - 100).
13+
14+
Returns:
15+
numpy array with the calculated actual vapor pressure
16+
"""
17+
# Teten O. Über einige meteorologische Begriffe. Z. Geophys., 1930; 6. 297-309.
18+
# Murray FW. On the computation of saturation vapor pressure,
19+
# J. Appl. Meteorol., 1967; 6, 203-204
20+
es = 0.61078 * 10**(t_air_celcius*7.5 / (237.3+t_air_celcius))
21+
return es * rh/100
22+
23+
24+
def kpa_to_hpa(pressure):
25+
"""Function to convert pressure data in kPa to hPa.
26+
27+
Args:
28+
pressure (xr.DataArray): DataArray with pressure values in [kPa].
29+
30+
Returns:
31+
xr.DataArray: Pressure data [hPa].
32+
33+
Raises:
34+
ValueError if the input pressure is not in [kPa]
35+
"""
36+
return pressure * 10
37+
38+
39+
def pa_to_hpa(pressure):
40+
"""Function to convert pressure data in Pa to hPa.
41+
42+
Args:
43+
pressure (xr.DataArray): DataArray with pressure values in Pascal.
44+
45+
Returns:
46+
xr.DataArray: Pressure data [hPa].
47+
48+
Raises:
49+
ValueError if the input pressure is not in Pascal
50+
"""
51+
if pressure.units == 'Pa':
52+
return pressure / 100
53+
raise ValueError('Input pressure is not in [Pa]')
54+
55+
56+
def kelvin_to_celcius(temperature):
57+
"""Function to convert temperature data in Kelvin to deg Celcius
58+
59+
Args:
60+
temperature (xr.DataArray): DataArray with temperature values in Kelvin.
61+
62+
Returns:
63+
xr.DataArray: Temperature in degrees Celcius.
64+
65+
Raises:
66+
ValueError if the input temperature is not in Kelvin
67+
"""
68+
if temperature.units=='K':
69+
return temperature - 273.15
70+
raise ValueError('Input temperature is not in [K]')
71+
72+
73+
def co2_molar_fraction_to_kg_per_m3(molar_fraction):
74+
"""Function to convert CO2 molar fraction [mol cO2/mol air] to CO2
75+
concentration in [kg CO2 / m3 air]
76+
77+
Note: the density of air [kg/m3] used for the calculation is assumed to be constant
78+
here, but will vary depending on the air pressure and air temperature.
79+
80+
Args:
81+
molar_fraction (float, np.array): CO2 concentration as molar fraction
82+
83+
Returns:
84+
Same as input: CO2 concentration in [kg CO2 / m3 air]
85+
"""
86+
molecular_weight_co2 = 44.01 # [kg/mol]
87+
avg_density_air = 1.292 # [kg/m3]
88+
avg_molar_mass_air = 28.9647 # [kg/mol]
89+
molar_density_air = avg_molar_mass_air / avg_density_air # [m3/mol]
90+
return molar_fraction * molecular_weight_co2 / molar_density_air
91+
92+
93+
def precipitation_mm_s_to_cm_s(precipitation_rate):
94+
"""Function to convert precipitation rate in [kg/m2/s] or [mm/s] to [cm/s]
95+
96+
Args:
97+
precipitation_rate (float, np.array): Precipitation rate in [kg/m2/s] or [mm/s].
98+
99+
Returns:
100+
Same as input: Precipitation rate in [cm/s]
101+
"""
102+
return precipitation_rate/10
103+
104+
105+
def mask_data(data, min_value=None, max_value=None):
106+
"""Function to apply a mask to data.
107+
108+
Args:
109+
data (np.array): Array containing the original data.
110+
condition (np.array): Boolean array to mask the data with.
111+
value (float): Value to replaced the masked data with.
112+
113+
Returns:
114+
np.array: Array where the values under the mask have been replaced with the
115+
given value.
116+
"""
117+
return np.clip(data, min_value, max_value)

notebooks/run_model_in_notebook.ipynb

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"import os\n",
1818
"from pathlib import Path\n",
1919
"import subprocess\n",
20-
"import PyStemmusScope"
20+
"from PyStemmusScope import forcing_io\n",
21+
"from PyStemmusScope import iostreamer"
2122
]
2223
},
2324
{
@@ -46,7 +47,7 @@
4647
"# path to config file\n",
4748
"path_to_config_file = \"path_to_config_file\"\n",
4849
"# Instantiate working directories handler from PyStemmusScope\n",
49-
"config = PyStemmusScope.read_config(path_to_config_file)"
50+
"config_template = iostreamer.read_config(path_to_config_file)"
5051
]
5152
},
5253
{
@@ -79,7 +80,7 @@
7980
}
8081
],
8182
"source": [
82-
"config"
83+
"config_template"
8384
]
8485
},
8586
{
@@ -113,8 +114,8 @@
113114
],
114115
"source": [
115116
"# edit config\n",
116-
"config[\"NumberOfTimeSteps\"] = \"5\"\n",
117-
"config"
117+
"config_template[\"NumberOfTimeSteps\"] = \"5\"\n",
118+
"config_template"
118119
]
119120
},
120121
{
@@ -145,15 +146,20 @@
145146
"\n",
146147
"full_run = False\n",
147148
"if full_run:\n",
148-
" forcing_filenames_list = [file.name for file in Path(config[\"ForcingPath\"]).iterdir()]\n",
149+
" forcing_filenames_list = [file.name for file in Path(config_template[\"ForcingPath\"]).iterdir()]\n",
149150
"\n",
150151
"# empty dict to store path to config file and work directory for each station\n",
151152
"config_path_dict = {}\n",
152153
"input_dir_dict = {}\n",
153154
"for nc_file in forcing_filenames_list:\n",
154-
" input_dir, output_dir, config_path = PyStemmusScope.create_io_dir(nc_file, config)\n",
155+
" input_dir, output_dir, config_path = iostreamer.create_io_dir(nc_file, config_template)\n",
155156
" config_path_dict[nc_file] = config_path\n",
156157
" input_dir_dict[nc_file] = input_dir\n",
158+
" \n",
159+
" config_run = iostreamer.read_config(config_path)\n",
160+
"\n",
161+
" forcing_io.prepare_forcing(input_dir, Path(config_run[\"ForcingPath\"]) / nc_file, config_run)\n",
162+
" \n",
157163
" print(input_dir, output_dir, config_path)"
158164
]
159165
},
@@ -277,7 +283,7 @@
277283
"name": "python",
278284
"nbconvert_exporter": "python",
279285
"pygments_lexer": "ipython3",
280-
"version": "3.9.12"
286+
"version": "3.9.13"
281287
}
282288
},
283289
"nbformat": 4,

0 commit comments

Comments
 (0)