nfdata 0.1.0__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.
- config.create.example.yaml +228 -0
- config.edit.example.yaml +14 -0
- environment.yaml +13 -0
- nfdata/__init__.py +9 -0
- nfdata/compiler.py +700 -0
- nfdata/console.py +32 -0
- nfdata/land_use.default.yaml +58 -0
- nfdata/model_vars.yaml +224 -0
- nfdata/router.py +111 -0
- nfdata-0.1.0.dist-info/METADATA +104 -0
- nfdata-0.1.0.dist-info/RECORD +13 -0
- nfdata-0.1.0.dist-info/WHEEL +4 -0
- nfdata-0.1.0.dist-info/entry_points.txt +3 -0
nfdata/compiler.py
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
import importlib.resources as pkg_resources
|
|
5
|
+
from netCDF4 import Dataset
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import geopandas as gpd
|
|
9
|
+
import rasterio
|
|
10
|
+
from rasterio.crs import CRS
|
|
11
|
+
from rasterio.mask import mask
|
|
12
|
+
from rasterio.warp import reproject, Resampling
|
|
13
|
+
from shapely.geometry import box
|
|
14
|
+
from ruamel.yaml import YAML
|
|
15
|
+
from pint import UnitRegistry
|
|
16
|
+
import f90nml
|
|
17
|
+
from .router import Router
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Compiler:
|
|
21
|
+
|
|
22
|
+
def __init__(self, task, config_path, model_vars_path):
|
|
23
|
+
"""Initialise the compiler, either to compile or edit the dataset."""
|
|
24
|
+
# Open the config and model_vars files
|
|
25
|
+
self.yaml = YAML(typ='safe')
|
|
26
|
+
with open(config_path, 'r') as config_file, open(model_vars_path, 'r') as model_vars_file:
|
|
27
|
+
self.config = self.yaml.load(config_file)
|
|
28
|
+
self.vars = self.yaml.load(model_vars_file)
|
|
29
|
+
# Get the land use file path from the config file. Use default land use if not present
|
|
30
|
+
land_use_file = self.config['land_use_config'] if 'land_use_config' in self.config \
|
|
31
|
+
else pkg_resources.files(__package__).joinpath('land_use.default.yaml')
|
|
32
|
+
# Open this YAML file and store in land_use_config dict
|
|
33
|
+
with open(land_use_file, 'r') as land_use_config_file:
|
|
34
|
+
self.land_use_config = self.yaml.load(land_use_config_file)
|
|
35
|
+
# Combine config and model_vars
|
|
36
|
+
for k, v in self.config.items():
|
|
37
|
+
if k in self.vars:
|
|
38
|
+
self.vars[k].update(v)
|
|
39
|
+
# If path not in vars dict, then we can't do anything, so remove. This is likely because
|
|
40
|
+
# it didn't appear in config.yaml.
|
|
41
|
+
self.vars = {k: v for k, v in self.vars.items() if 'path' in v}
|
|
42
|
+
# Parse the paths, substituting <root_dir> and adding to path
|
|
43
|
+
self.parse_paths()
|
|
44
|
+
# Add the separate land use category converter array
|
|
45
|
+
if 'land_use' in self.vars:
|
|
46
|
+
self.vars['land_use']['cat_conv_dict'] = self.land_use_config
|
|
47
|
+
# Get a list of constants, spatial and spatiotemporal variables
|
|
48
|
+
self.vars_spatial = []
|
|
49
|
+
self.vars_spatial_1d = []
|
|
50
|
+
self.vars_spatial_point = []
|
|
51
|
+
self.vars_spatiotemporal = []
|
|
52
|
+
for k, v in self.vars.items():
|
|
53
|
+
if ('dims' in v) and (v['dims'] == ['y', 'x']):
|
|
54
|
+
self.vars_spatial.append(k)
|
|
55
|
+
elif ('dims' in v) and (v['dims'] == ['t', 'y', 'x']):
|
|
56
|
+
self.vars_spatiotemporal.append(k)
|
|
57
|
+
elif ('dims' in v) and (v['dims'] == ['p', 't', 'y', 'x']):
|
|
58
|
+
self.vars_spatial_point.append(k)
|
|
59
|
+
elif ('dims' in v) and (all(x in v['dims'] for x in ['y', 'x'])) and (len(v['dims']) == 3):
|
|
60
|
+
self.vars_spatial_1d.append(k)
|
|
61
|
+
# Setup the unit registry
|
|
62
|
+
self.ureg = UnitRegistry()
|
|
63
|
+
# Was a root directory specified?
|
|
64
|
+
self.root_dir = self.config['root_dir'] if 'root_dir' in self.config else ''
|
|
65
|
+
# Create empty dict ready to save vars to memory
|
|
66
|
+
self.saved_vars = {}
|
|
67
|
+
# Do we want to compile or edit a dataset?
|
|
68
|
+
if task == 'create':
|
|
69
|
+
self.__init_to_compile()
|
|
70
|
+
elif task == 'edit':
|
|
71
|
+
self.__init_to_edit()
|
|
72
|
+
|
|
73
|
+
def __init_to_compile(self):
|
|
74
|
+
"""Initialise the dataset for compilation from scratch. Reads the config and model_var files,
|
|
75
|
+
combines these and generates a list of vars according to dimensionality.
|
|
76
|
+
Also sets up the unit registry and defines 'timestep' as a unit."""
|
|
77
|
+
with open(self.config['constants_file'], 'r') as constants_file:
|
|
78
|
+
self.constants = self.yaml.load(constants_file)
|
|
79
|
+
# Define the timestep as a unit, based on that given in config file
|
|
80
|
+
self.ureg.define('timestep = {0} * second'.format(self.config['time']['dt']))
|
|
81
|
+
|
|
82
|
+
def __init_to_edit(self):
|
|
83
|
+
"""Initialise the compiler for editing a dataset."""
|
|
84
|
+
# Check we're not trying to edit one of the following variables. We're looping over self.config.keys()
|
|
85
|
+
# here because flow_dir will have been removed from self.vars already
|
|
86
|
+
for var in self.config.keys():
|
|
87
|
+
if var in ['flow_dir', 'is_estuary']:
|
|
88
|
+
sys.exit(f'Sorry, editing the {var} variable isn\'t allowed. Create a new dataset instead.')
|
|
89
|
+
# If output_nc_path given, make a copy of the NetCDF file first
|
|
90
|
+
if 'output_nc_file' in self.config:
|
|
91
|
+
shutil.copy(self.config['input_nc_file'], self.config['output_nc_file'])
|
|
92
|
+
else:
|
|
93
|
+
self.config['output_nc_file'] = self.config['input_nc_file']
|
|
94
|
+
# Open the NetCDF dataset
|
|
95
|
+
self.nc = Dataset(self.config['output_nc_file'], 'r+')
|
|
96
|
+
# Get timestep info from the NetCDF file. Start date not needed for editing, so we won't retrieve this
|
|
97
|
+
self.config['time'] = {
|
|
98
|
+
'n': len(self.nc['t']), # Get number of timesteps from the length of t variable
|
|
99
|
+
'dt': self.nc['t'][1] - self.nc['t'][0] # Get the timestep length from difference between timesteps
|
|
100
|
+
}
|
|
101
|
+
# Define the timestep as a unit, based on that given in config file
|
|
102
|
+
self.ureg.define('timestep = {0} * second'.format(self.config['time']['dt']))
|
|
103
|
+
# Set grid properties from the NetCDF file
|
|
104
|
+
self.grid = rasterio.open(f'netcdf:{self.config["output_nc_file"]}:flow_dir')
|
|
105
|
+
self.grid_crs = CRS.from_wkt(self.nc['crs'].crs_wkt)
|
|
106
|
+
self.grid_bbox = box(*self.grid.bounds)
|
|
107
|
+
self.grid_mask = np.ma.getmask(self.grid.read(1, masked=True))
|
|
108
|
+
|
|
109
|
+
def create(self):
|
|
110
|
+
"""Compile the data from multiple input files, specified in the config file, to a NetCDF dataset for
|
|
111
|
+
spatial and/or temporal data, and a Fortran namelist file for constants."""
|
|
112
|
+
# Set up the dataset:
|
|
113
|
+
# - Use flow direction raster to give us the grid system (bounds, CRS, etc)
|
|
114
|
+
# - Creating the NetCDF dataset with this grid information
|
|
115
|
+
# - Set tidal bounds in lieu of routing
|
|
116
|
+
# - Route water bodies (e.g. set inflows, outflows and headwaters) using the flow direction and tidal bounds
|
|
117
|
+
print('Setting up dataset...\n'
|
|
118
|
+
'\t...parsing flow_dir')
|
|
119
|
+
self.parse_flow_dir()
|
|
120
|
+
print('\t...creating NetCDF file')
|
|
121
|
+
self.setup_netcdf_dataset()
|
|
122
|
+
self.parse_spatial_var('is_estuary', save=True)
|
|
123
|
+
self.vars_spatial.remove('is_estuary') # Make sure we don't create is_estuary twice
|
|
124
|
+
print('\t...routing water bodies')
|
|
125
|
+
self.routing()
|
|
126
|
+
# Create the variables
|
|
127
|
+
print("Creating variables...")
|
|
128
|
+
# Constants - converts the YAML constants file to Fortran namelist format
|
|
129
|
+
print('\t...constants')
|
|
130
|
+
self.parse_constants()
|
|
131
|
+
# Spatial, non-temporal data
|
|
132
|
+
for var in self.vars_spatial:
|
|
133
|
+
print(f'\t...{var}')
|
|
134
|
+
self.parse_spatial_var(var)
|
|
135
|
+
# Spatial data with 1 record dimension (that isn't time)
|
|
136
|
+
for var in self.vars_spatial_1d:
|
|
137
|
+
print(f'\t...{var}')
|
|
138
|
+
self.parse_spatial_1d_var(var)
|
|
139
|
+
# Spatial point data
|
|
140
|
+
for var in self.vars_spatial_point:
|
|
141
|
+
print(f'\t...{var}')
|
|
142
|
+
self.parse_spatial_point_var(var)
|
|
143
|
+
# Spatiotemporal data
|
|
144
|
+
for var in self.vars_spatiotemporal:
|
|
145
|
+
print(f'\t...{var}')
|
|
146
|
+
self.parse_spatiotemporal_var(var)
|
|
147
|
+
# We're done! Report where the data have been saved to
|
|
148
|
+
print(f'Done! Data saved to...\n\t'
|
|
149
|
+
f'{self.config["output"]["nc_file"]}\n\t'
|
|
150
|
+
f'{self.config["output"]["constants_file"]}')
|
|
151
|
+
|
|
152
|
+
def edit(self):
|
|
153
|
+
"""Edit the specified variables in NetCDF file."""
|
|
154
|
+
# Spatial, non-temporal data
|
|
155
|
+
print("Editing variables...")
|
|
156
|
+
for var in self.vars_spatial:
|
|
157
|
+
print(f'\t...{var}')
|
|
158
|
+
self.parse_spatial_var(var)
|
|
159
|
+
# Spatial data with 1 record dimension (that isn't time)
|
|
160
|
+
for var in self.vars_spatial_1d:
|
|
161
|
+
print(f'\t...{var}')
|
|
162
|
+
self.parse_spatial_1d_var(var)
|
|
163
|
+
# Spatial point data
|
|
164
|
+
for var in self.vars_spatial_point:
|
|
165
|
+
print(f'\t...{var}')
|
|
166
|
+
self.parse_spatial_point_var(var)
|
|
167
|
+
# Spatiotemporal data
|
|
168
|
+
for var in self.vars_spatiotemporal:
|
|
169
|
+
print(f'\t...{var}')
|
|
170
|
+
self.parse_spatiotemporal_var(var)
|
|
171
|
+
# We're done! Report where the data have been saved to
|
|
172
|
+
print(f'Done! Data saved to {self.config["output_nc_file"]}')
|
|
173
|
+
|
|
174
|
+
def parse_paths(self):
|
|
175
|
+
"""Search for <root_dir> in config variable configs and replace with root_dir as
|
|
176
|
+
as specified in config file."""
|
|
177
|
+
for k, v in self.vars.items():
|
|
178
|
+
if '<root_dir>' in v['path']:
|
|
179
|
+
self.vars[k]['path'] = os.path.join(self.config['root_dir'], v['path'].split('<root_dir>')[1])
|
|
180
|
+
if 'temporal_profile' in v:
|
|
181
|
+
path = v['temporal_profile']['path']
|
|
182
|
+
if '<root_dir>' in path:
|
|
183
|
+
self.vars[k]['temporal_profile']['path'] \
|
|
184
|
+
= os.path.join(self.config['root_dir'], path.split('<root_dir>')[1])
|
|
185
|
+
|
|
186
|
+
def parse_flow_dir(self):
|
|
187
|
+
"""Parse the flow direction variable and define the CRS and mask for other
|
|
188
|
+
variables based on this. This step must be performed before NetCDF file is generated."""
|
|
189
|
+
if self.config['flow_dir']['type'] == 'raster':
|
|
190
|
+
self.grid = rasterio.open(self.config['flow_dir']['path'].replace('<root_dir>', self.config['root_dir']))
|
|
191
|
+
# If a CRS has been specified for the flowdir raster, use this instead of the raster's
|
|
192
|
+
# internal CRS. This is useful if a raster has an ill-defined CRS
|
|
193
|
+
self.grid_crs = CRS.from_user_input(self.config['flow_dir']['crs']) \
|
|
194
|
+
if 'crs' in self.config['flow_dir'] else self.grid.crs
|
|
195
|
+
# Only projected rasters allowed for the moment
|
|
196
|
+
if self.grid_crs.is_geographic:
|
|
197
|
+
print('Sorry, the flow_dir raster must be projected, not geographic. '
|
|
198
|
+
'I got a geographic CRS: \'{0}\'.'.format(self.grid_crs))
|
|
199
|
+
sys.exit()
|
|
200
|
+
# Create Shapely box from bounds, to clip other rasters to
|
|
201
|
+
self.grid_bbox = box(*self.grid.bounds)
|
|
202
|
+
elif self.config['flow_dir']['type'] == 'csv':
|
|
203
|
+
print("CSV flow direction not currently supported. Please provide a raster file.")
|
|
204
|
+
sys.exit()
|
|
205
|
+
|
|
206
|
+
else:
|
|
207
|
+
print("Unrecognised file type {0} for variable flow_dir. "
|
|
208
|
+
"Type should be rs, csv or nc.".format(self.config['flow_dir']['type']))
|
|
209
|
+
sys.exit()
|
|
210
|
+
|
|
211
|
+
def setup_netcdf_dataset(self):
|
|
212
|
+
"""Create NetCDF file, add required dimensions, coordinate variables and the
|
|
213
|
+
flow direction variable."""
|
|
214
|
+
self.nc = Dataset(self.config['output']['nc_file'], 'w', format='NETCDF4')
|
|
215
|
+
self.nc.title = "Input data for NanoFASE model"
|
|
216
|
+
self.nc.nanomaterial = self.config['nanomaterial'] if 'nanomaterial' in self.config else 'Unknown'
|
|
217
|
+
self.nc.Conventions = 'CF-1.8'
|
|
218
|
+
self.nc.coordinates = 'crs' # Needed for xarray to distinguish crs as a coordinate rather than a variable
|
|
219
|
+
crs_var = self.nc.createVariable('crs', 'i4')
|
|
220
|
+
crs_var.spatial_ref = self.grid_crs.to_wkt() # QGIS/ArcGIS recognises spatial_ref to define CRS
|
|
221
|
+
crs_var.crs_wkt = self.grid_crs.to_wkt() # Latest CF conventions say crs_wkt can be used
|
|
222
|
+
crs_var.epsg_code = self.grid_crs.to_epsg() # Not standardised, but might be useful instead of having to decipher WKT
|
|
223
|
+
# Time dimensions and coordinate variable
|
|
224
|
+
t_dim = self.nc.createDimension('t', None)
|
|
225
|
+
t = self.nc.createVariable('t', 'i4', ('t',))
|
|
226
|
+
t.units = "seconds since {0} 00:00:00".format(self.config['time']['start_date'])
|
|
227
|
+
t.standard_name = 'time'
|
|
228
|
+
t.calendar = 'gregorian'
|
|
229
|
+
t[:] = [i*int(self.config['time']['dt']) for i in range(int(self.config['time']['n']))]
|
|
230
|
+
# x dimension and coordinate variable
|
|
231
|
+
x_dim = self.nc.createDimension('x', self.grid.width)
|
|
232
|
+
x = self.nc.createVariable('x', 'f4', ('x',))
|
|
233
|
+
x.units = 'm'
|
|
234
|
+
x.standard_name = 'projection_x_coordinate'
|
|
235
|
+
x.axis = 'X'
|
|
236
|
+
x[:] = [self.grid.bounds.left + i * self.grid.res[0] + 0.5 * self.grid.res[0] for i in range(self.grid.width)]
|
|
237
|
+
# y dimension and coordinate variable
|
|
238
|
+
y_dim = self.nc.createDimension('y', self.grid.height)
|
|
239
|
+
y = self.nc.createVariable('y', 'f4', ('y',))
|
|
240
|
+
y.units = 'm'
|
|
241
|
+
y.standard_name = 'projection_y_coordinate'
|
|
242
|
+
y.axis = 'Y'
|
|
243
|
+
y[:] = [self.grid.bounds.top - i * self.grid.res[1] - 0.5 * self.grid.res[1] for i in range(self.grid.height)]
|
|
244
|
+
# Grid dimension (2D), max number of waterbodies per cell and grid bounds
|
|
245
|
+
d_dim = self.nc.createDimension('d', 2)
|
|
246
|
+
w_dim = self.nc.createDimension('w', 7)
|
|
247
|
+
box_dim = self.nc.createDimension('box', 4)
|
|
248
|
+
p_dim = self.nc.createDimension('p')
|
|
249
|
+
# Grid properties - shape
|
|
250
|
+
grid_shape = self.nc.createVariable('grid_shape', 'i4', ('d',))
|
|
251
|
+
grid_shape.units = ''
|
|
252
|
+
grid_shape.long_name = 'number of grid cells along each (x,y) grid axis'
|
|
253
|
+
grid_shape[:] = self.grid.shape[::-1]
|
|
254
|
+
# Grid resolution
|
|
255
|
+
grid_res = self.nc.createVariable('grid_res', 'f4', ('d',))
|
|
256
|
+
grid_res.units = ''
|
|
257
|
+
grid_res.long_name = 'size of each grid cell'
|
|
258
|
+
grid_res[:] = self.grid.res[::-1]
|
|
259
|
+
# Grid bounds
|
|
260
|
+
grid_bounds = self.nc.createVariable('grid_bounds', 'f4', ('box',))
|
|
261
|
+
grid_bounds.units = ''
|
|
262
|
+
grid_bounds.long_name = 'bounding box of the grid'
|
|
263
|
+
grid_bounds[:] = self.grid.bounds
|
|
264
|
+
# Get the flow direction array from the raster
|
|
265
|
+
self.flow_dir = self.grid.read(1, masked=True)
|
|
266
|
+
# Use the extent of the flow direction array to create a mask for all other data
|
|
267
|
+
self.grid_mask = np.ma.getmask(self.flow_dir)
|
|
268
|
+
nc_var = self.nc.createVariable('flow_dir', 'i4', ('y', 'x'))
|
|
269
|
+
nc_var.long_name = 'flow direction of water in grid cell'
|
|
270
|
+
nc_var[:] = self.flow_dir
|
|
271
|
+
|
|
272
|
+
def setup_netcdf_var(self, var_name, extra_dims=None, coords_sidecar=False):
|
|
273
|
+
var_dict = self.vars[var_name]
|
|
274
|
+
fill_value = float(var_dict['fill_value']) if 'fill_value' in var_dict else None
|
|
275
|
+
dims = tuple(var_dict['dims']) if 'dims' in var_dict else ()
|
|
276
|
+
# If an extra dimension has been supplied (e.g. a record dim), create this
|
|
277
|
+
# before adding to the variable
|
|
278
|
+
if extra_dims is not None:
|
|
279
|
+
for dim in extra_dims:
|
|
280
|
+
if dim[0] in dims:
|
|
281
|
+
nc_dim = self.nc.createDimension(dim[0], dim[1])
|
|
282
|
+
else:
|
|
283
|
+
print("Cannot find extra dimension {0} for NetCDF variable {1} in config file.".format(dim[0], var_name))
|
|
284
|
+
vartype = var_dict['vartype'] if 'vartype' in var_dict else 'f4'
|
|
285
|
+
if var_name not in self.nc.variables:
|
|
286
|
+
nc_var = self.nc.createVariable(var_name, vartype, dims, fill_value=fill_value)
|
|
287
|
+
else:
|
|
288
|
+
nc_var = self.nc[var_name]
|
|
289
|
+
if 'standard_name' in var_dict:
|
|
290
|
+
nc_var.standard_name = var_dict['standard_name']
|
|
291
|
+
if 'long_name' in var_dict:
|
|
292
|
+
nc_var.long_name = var_dict['long_name']
|
|
293
|
+
if 'source' in var_dict:
|
|
294
|
+
nc_var.source = var_dict['source']
|
|
295
|
+
if 'references' in var_dict:
|
|
296
|
+
nc_var.references = var_dict['references']
|
|
297
|
+
nc_var.units = var_dict['to_units']
|
|
298
|
+
nc_var.grid_mapping = 'crs'
|
|
299
|
+
# Should we be adding a coordinate sidebar variable (e.g. for point sources)?
|
|
300
|
+
if coords_sidecar:
|
|
301
|
+
if f'{var_name}_coords' not in self.nc.variables:
|
|
302
|
+
nc_var_coords = self.nc.createVariable(f'{var_name}_coords', np.float32, ('d', 'p', 'y', 'x'))
|
|
303
|
+
else:
|
|
304
|
+
nc_var_coords = self.nc[f'{var_name}_coords']
|
|
305
|
+
nc_var_coords.long_name = 'Exact coordinates for values in {0}'.format(var_name)
|
|
306
|
+
nc_var.units = 'm'
|
|
307
|
+
nc_var.grid_mapping = 'crs'
|
|
308
|
+
return nc_var, nc_var_coords
|
|
309
|
+
else:
|
|
310
|
+
return nc_var
|
|
311
|
+
|
|
312
|
+
def parse_constants(self):
|
|
313
|
+
"""Turn the constant YAML file into a Fortran namelist file."""
|
|
314
|
+
with open(self.config['output']['constants_file'], 'w') as nml_file:
|
|
315
|
+
allocatable_array_sizes = {}
|
|
316
|
+
for grp in self.constants.values():
|
|
317
|
+
for k, v in grp.items():
|
|
318
|
+
if isinstance(v, list):
|
|
319
|
+
allocatable_array_sizes['n_{0}'.format(k)] = len(v)
|
|
320
|
+
f90nml.write({'allocatable_array_sizes' : allocatable_array_sizes}, nml_file)
|
|
321
|
+
f90nml.write(self.constants, nml_file)
|
|
322
|
+
|
|
323
|
+
def parse_raster(self, var_name, units, path=None):
|
|
324
|
+
"""Parse a variable (or timestep of a variable) given by raster."""
|
|
325
|
+
var_dict = self.vars[var_name]
|
|
326
|
+
if path is None:
|
|
327
|
+
path = var_dict['path']
|
|
328
|
+
# Open the raster and clip to extent of grid (defined by flowdir raster)
|
|
329
|
+
with rasterio.open(path) as rs:
|
|
330
|
+
out_bounds = rs.bounds
|
|
331
|
+
# Mask clips the raster to the grid bounding box or smaller
|
|
332
|
+
out_img, _ = mask(rs, [self.grid_bbox], crop=True, filled=False)
|
|
333
|
+
# The raster might still be smaller than the grid box, so let's check and
|
|
334
|
+
# construct mask array of the correct size if so
|
|
335
|
+
if out_bounds.left > self.grid.bounds.left or out_bounds.right < self.grid.bounds.right \
|
|
336
|
+
or out_bounds.top < self.grid.bounds.top or out_bounds.bottom > self.grid.bounds.bottom:
|
|
337
|
+
# Get the xy pixel within the grid box of the input raster bounds
|
|
338
|
+
out_ij_within_grid = rasterio.transform.rowcol(self.grid.transform, out_bounds.left, out_bounds.bottom)
|
|
339
|
+
# Create a completely masked array with the same size as the grid
|
|
340
|
+
values = np.ma.masked_all(self.grid.shape)
|
|
341
|
+
# Overwrite the relevant pixels from out_img. Masks will be retained on elements that are already masked
|
|
342
|
+
values[out_ij_within_grid[0]:out_ij_within_grid[0] + out_img[0].shape[0],
|
|
343
|
+
out_ij_within_grid[1]:out_ij_within_grid[1] + out_img[0].shape[1]] = out_img[0]
|
|
344
|
+
else:
|
|
345
|
+
# If the raster has the correct bounding box, we can just mask it using the grid mask
|
|
346
|
+
values = np.ma.masked_where(self.grid_mask, out_img[0])
|
|
347
|
+
# Should the array be clipped (numerically, not geographically)?
|
|
348
|
+
if 'clip' in var_dict:
|
|
349
|
+
try:
|
|
350
|
+
min = np.array(var_dict['clip'][0]).astype(values.dtype)
|
|
351
|
+
except ValueError:
|
|
352
|
+
min = None
|
|
353
|
+
try:
|
|
354
|
+
max = np.array(var_dict['clip'][1]).astype(values.dtype)
|
|
355
|
+
except ValueError:
|
|
356
|
+
max = None
|
|
357
|
+
np.clip(values, min, max, out=values)
|
|
358
|
+
|
|
359
|
+
# Do the unit conversion
|
|
360
|
+
values = units[0] * values
|
|
361
|
+
values.ito(units[1])
|
|
362
|
+
# Return the values
|
|
363
|
+
return values
|
|
364
|
+
|
|
365
|
+
def parse_spatial_var(self, var_name, save=False):
|
|
366
|
+
# Create and fill attributes in NetCDF file for given variable.
|
|
367
|
+
nc_var = self.setup_netcdf_var(var_name)
|
|
368
|
+
var_dict = self.vars[var_name]
|
|
369
|
+
|
|
370
|
+
# Check if we're converting units
|
|
371
|
+
from_units = self.ureg(var_dict['units'] if 'units' in var_dict else var_dict['to_units'])
|
|
372
|
+
to_units = self.ureg(var_dict['to_units'])
|
|
373
|
+
if from_units != to_units:
|
|
374
|
+
print('\t\t...converting {0.units:~P} to {1.units:~P}'.format(from_units, to_units))
|
|
375
|
+
|
|
376
|
+
# Is the data supplied in raster or csv form?
|
|
377
|
+
if var_dict['type'] in ['raster', 'nc']:
|
|
378
|
+
# Parse the raster (clip, convert units)
|
|
379
|
+
values = self.parse_raster(var_name, (from_units, to_units))
|
|
380
|
+
# Fill the NetCDF variable with the clipped raster (without the units)
|
|
381
|
+
nc_var[:] = values.magnitude
|
|
382
|
+
if save:
|
|
383
|
+
self.saved_vars[var_name] = values.magnitude
|
|
384
|
+
elif var_dict['type'] == 'csv':
|
|
385
|
+
# TODO
|
|
386
|
+
print("Sorry, only raster spatial variables supported at the moment. Variable: {0}.".format(var_name))
|
|
387
|
+
else:
|
|
388
|
+
print("Unrecognised file type {0} for variable {1}. Type should be raster, csv or nc.".format(var_dict['type'], var_name))
|
|
389
|
+
|
|
390
|
+
def parse_spatial_point_var(self, var_name):
|
|
391
|
+
# Get the var dict, but don't create the NetCDF var yet as we need to parse data before
|
|
392
|
+
# we know the max length of the points per cell dimension
|
|
393
|
+
var_dict = self.vars[var_name]
|
|
394
|
+
|
|
395
|
+
# Check if we're converting units
|
|
396
|
+
from_units = self.ureg(var_dict['units'] if 'units' in var_dict else var_dict['to_units'])
|
|
397
|
+
to_units = self.ureg(var_dict['to_units'])
|
|
398
|
+
if from_units != to_units:
|
|
399
|
+
print('\t\t...converting {0.units:~P} to {1.units:~P}'.format(from_units, to_units))
|
|
400
|
+
|
|
401
|
+
# Is the data in shapefile format (the only supported format for the time being)?
|
|
402
|
+
if var_dict['type'] == 'shapefile':
|
|
403
|
+
# Parse the Shapefile
|
|
404
|
+
values, coords = self.parse_shapefile(var_name, (from_units, to_units))
|
|
405
|
+
nc_var, nc_var_coords = self.setup_netcdf_var(var_name, coords_sidecar=True)
|
|
406
|
+
nc_var[:, :, :, :] = values.magnitude
|
|
407
|
+
nc_var_coords[:, :, :, :] = coords
|
|
408
|
+
# TODO what to do about temporal point sources?
|
|
409
|
+
elif var_dict['type'] == 'csv':
|
|
410
|
+
# TODO
|
|
411
|
+
print("Sorry, only shapefile point variables supported at the moment. Variable: {0}.".format(var_name))
|
|
412
|
+
else:
|
|
413
|
+
print("Unrecognised file type {0} for variable {1}. Type should be raster, csv or nc.".format(var_dict['type'], var_name))
|
|
414
|
+
|
|
415
|
+
def parse_spatial_1d_var(self, var_name):
|
|
416
|
+
var_dict = self.vars[var_name]
|
|
417
|
+
record_dim = [d for d in var_dict['dims'] if d not in ['x', 'y']][0] # Get the dim that isn't x or y
|
|
418
|
+
|
|
419
|
+
# If this is land use, we need to do some pre-processing first to convert supplied land use categories
|
|
420
|
+
# to the NanoFASE land use categories
|
|
421
|
+
if var_name == 'land_use':
|
|
422
|
+
self.parse_land_use(record_dim)
|
|
423
|
+
else:
|
|
424
|
+
nc_var = self.setup_netcdf_var(var_name)
|
|
425
|
+
from_units = self.ureg(var_dict['units'] if 'units' in var_dict else var_dict['to_units'])
|
|
426
|
+
to_units = self.ureg(var_dict['to_units'])
|
|
427
|
+
if from_units != to_units:
|
|
428
|
+
print('\t\t...converting {0.units:~P} to {1.units:~P}'.format(from_units, to_units))
|
|
429
|
+
|
|
430
|
+
if var_dict['type'] == 'raster':
|
|
431
|
+
# If the {record_dim} tag is in the path, there must be one raster per record dim
|
|
432
|
+
if '{' + record_dim + '}' in var_dict['path']:
|
|
433
|
+
print("Sorry, record dimension of spatial 1d variables must be given as separate bands, for the moment.")
|
|
434
|
+
else:
|
|
435
|
+
print("One band per record dim")
|
|
436
|
+
else:
|
|
437
|
+
print("Unrecognised file type {0} for variable {1}. Type should be raster for 1d spatial variables.".format(config[var]['type'], var))
|
|
438
|
+
|
|
439
|
+
def parse_spatiotemporal_var(self, var_name):
|
|
440
|
+
nc_var = self.setup_netcdf_var(var_name)
|
|
441
|
+
var_dict = self.vars[var_name]
|
|
442
|
+
|
|
443
|
+
# Check if we're converting units (the actual converting is done per timestep, below)
|
|
444
|
+
from_units = self.ureg(var_dict['units'] if 'units' in var_dict else var_dict['to_units'])
|
|
445
|
+
to_units = self.ureg(var_dict['to_units'])
|
|
446
|
+
if from_units != to_units:
|
|
447
|
+
print('\t\t...converting {0.units:~P} to {1.units:~P}'.format(from_units, to_units))
|
|
448
|
+
|
|
449
|
+
# Is this a raster or CSV?
|
|
450
|
+
if var_dict['type'] == 'raster':
|
|
451
|
+
# If the {t} tag is in the path, there must be one raster file per timestep
|
|
452
|
+
if '{t}' in var_dict['path']:
|
|
453
|
+
# Zero-indexed or higher?
|
|
454
|
+
t_min = 0 if ('t_min' not in var_dict) or (var_dict['t_min'] is None) else int(var_dict['t_min'])
|
|
455
|
+
# Loop through the time steps and parse raster for each
|
|
456
|
+
for t in range(t_min, int(self.config['time']['n']) + t_min):
|
|
457
|
+
path = var_dict['path'].replace('{t}', str(t))
|
|
458
|
+
values = self.parse_raster(var_name, (from_units, to_units), path)
|
|
459
|
+
# Add this time step to the NetCDF file as a masked array
|
|
460
|
+
nc_var[t-1, :, :] = values.magnitude
|
|
461
|
+
else:
|
|
462
|
+
print("Spatiotemporal variable ({0}) in raster format must be provided by one raster file per time step, with path denoted by /{t/}".format(var_name))
|
|
463
|
+
|
|
464
|
+
elif var_dict['type'] == 'csv':
|
|
465
|
+
df = pd.read_csv(var_dict['path'], header=0)
|
|
466
|
+
# Loop through the timesteps and create pivot table to obtain spatial array for each
|
|
467
|
+
for t in range(1,df['t'].max()+1):
|
|
468
|
+
df_t = df[df['t'] == t]
|
|
469
|
+
pt = df_t.pivot_table(index='y', columns='x', values=var_name)
|
|
470
|
+
values = np.ma.masked_where(self.grid_mask, pt.values)
|
|
471
|
+
# Check the pivot table's shape is that of the grid we're using
|
|
472
|
+
if pt.shape != self.grid.shape:
|
|
473
|
+
print("Inconsistent shape between {0} csv file and overall grid system ({1} and {2}). Check indices set correctly.".format(var, pt.shape, grid.shape))
|
|
474
|
+
sys.exit()
|
|
475
|
+
# Should the array be clipped?
|
|
476
|
+
if 'clip' in var_dict:
|
|
477
|
+
try:
|
|
478
|
+
min = float(var_dict['clip'][0])
|
|
479
|
+
except ValueError:
|
|
480
|
+
min = None
|
|
481
|
+
try:
|
|
482
|
+
max = float(var_dict['clip'][1])
|
|
483
|
+
except ValueError:
|
|
484
|
+
max = None
|
|
485
|
+
np.clip(values, min, max, out=values)
|
|
486
|
+
# Convert units if "units" specified in config, to the to_units in model_vars
|
|
487
|
+
values = from_units * values
|
|
488
|
+
values.ito(to_units)
|
|
489
|
+
# Add this time step to the NetCDF file as a masked array
|
|
490
|
+
nc_var[t-1,:,:] = values.magnitude
|
|
491
|
+
else:
|
|
492
|
+
print("Unrecognised file type {0} for variable {1}. Type should be raster or csv.".format(var_dict['type'], var_name))
|
|
493
|
+
|
|
494
|
+
def parse_shapefile(self, var_name, units):
|
|
495
|
+
"""Parse a shapefile of point values into the model grid, with dimensions ['p', 'y', 'x'],
|
|
496
|
+
where [p] is each point in the grid cell (x,y). A second array describing the location of
|
|
497
|
+
each of these points with the same dimensions (plus [d] as they're coordinates) will also
|
|
498
|
+
be created."""
|
|
499
|
+
var_dict = self.vars[var_name]
|
|
500
|
+
|
|
501
|
+
# Check if we have a temporal profile
|
|
502
|
+
if 'temporal_profile' in var_dict:
|
|
503
|
+
# Load the temporal profile CSV and create list of temporal factors. These are interpolated
|
|
504
|
+
# if the time step is not 1 day (the temporal factor time step)
|
|
505
|
+
df = pd.read_csv(var_dict['temporal_profile']['path'], header=0, sep=';')
|
|
506
|
+
df = df[(df['ISO3'] == self.config['iso3'].upper()) & (df[var_dict['temporal_profile']['source_type_col']] == var_dict['temporal_profile']['for_source_type'])]
|
|
507
|
+
temporal_factors_data = df[var_dict['temporal_profile']['factor_col']].tolist()
|
|
508
|
+
# Do the interpolation
|
|
509
|
+
temporal_factors = np.interp(
|
|
510
|
+
np.arange(0, int(self.config['time']['n']) * int(self.config['time']['dt']), int(self.config['time']['dt'])), # The desired x temporal res
|
|
511
|
+
np.arange(0, 86400 * len(temporal_factors_data), 86400), # The given x temporal res (presuming daily)
|
|
512
|
+
temporal_factors_data, # The provided temporal factors
|
|
513
|
+
)
|
|
514
|
+
gdf = gpd.read_file(self.vars[var_name]['path'])
|
|
515
|
+
# Create empty values array and set a maximum of 100 point sources
|
|
516
|
+
values = np.ma.array(np.ma.empty((10, int(self.config['time']['n']), *self.grid.shape), dtype=np.float64), mask=True)
|
|
517
|
+
coords = np.ma.array(np.ma.empty((2, 10, *self.grid.shape), dtype=np.float32), mask=True)
|
|
518
|
+
# Loop through GeoDataFrame and fill values array
|
|
519
|
+
for index, point in gdf.iterrows():
|
|
520
|
+
if self.in_model_domain(point['geometry']):
|
|
521
|
+
# Get the indices of the cell this point is in
|
|
522
|
+
i = int(((int(point['geometry'].x) - int(point['geometry'].x) % self.grid.res[0]) - self.grid.bounds.left) / self.grid.res[0])
|
|
523
|
+
j = int((self.grid.bounds.top - (int(point['geometry'].y) - int(point['geometry'].y) % self.grid.res[1])) / self.grid.res[1]) - 1
|
|
524
|
+
# Find the next point element that isn't masked
|
|
525
|
+
p = 0
|
|
526
|
+
while values[p,0,j,i] is not np.ma.masked:
|
|
527
|
+
p = p + 1
|
|
528
|
+
if p >= values.shape[1]:
|
|
529
|
+
print("Maximum of {0} point sources allowed per cell, but cell {1},{2} (x,y zero-indexed) has more than that.".format(values.shape[1], i, j))
|
|
530
|
+
sys.exit()
|
|
531
|
+
# Which temporal profile should be applied?
|
|
532
|
+
if ('temporal_profile' in var_dict) and \
|
|
533
|
+
point[var_dict['source_type_col']] == var_dict['temporal_profile']['for_source_type']:
|
|
534
|
+
point_values = point[var_dict['value_var']] * temporal_factors
|
|
535
|
+
else:
|
|
536
|
+
point_values = [point[var_dict['value_var']]] * int(self.config['time']['n'])
|
|
537
|
+
values[p,:,j,i] = point_values
|
|
538
|
+
coords[:,p,j,i] = [point['geometry'].x, point['geometry'].y]
|
|
539
|
+
|
|
540
|
+
# Shrink to the max number of points
|
|
541
|
+
max_points_per_cell = values.count(axis=0).max()
|
|
542
|
+
values = np.ma.array(values[:max_points_per_cell.max(),:,:,:])
|
|
543
|
+
coords = np.ma.array(coords[:,:max_points_per_cell.max(),:,:])
|
|
544
|
+
|
|
545
|
+
# Clip the array to the grid mask. The broadcast_to function "broadcasts" the grid_mask as being the correct rank.
|
|
546
|
+
# See here: https://stackoverflow.com/questions/37682284/mask-a-3d-array-with-a-2d-mask-in-numpy
|
|
547
|
+
values = np.ma.masked_where(np.broadcast_to(self.grid_mask, values.shape), values)
|
|
548
|
+
coords = np.ma.masked_where(np.broadcast_to(self.grid_mask, coords.shape), coords)
|
|
549
|
+
# Convert the units
|
|
550
|
+
values = units[0] * values
|
|
551
|
+
values.ito(units[1])
|
|
552
|
+
|
|
553
|
+
return values, coords
|
|
554
|
+
|
|
555
|
+
def parse_land_use(self, cat_dim):
|
|
556
|
+
"""Convert the supplied raster of land use categories to a multi-band array of NanoFASE land
|
|
557
|
+
use categories."""
|
|
558
|
+
cat_conv_dict = self.vars['land_use']['cat_conv_dict']
|
|
559
|
+
nf_cats = self.vars['land_use']['cats']
|
|
560
|
+
|
|
561
|
+
# Open the supplied land use raster
|
|
562
|
+
# Open the raster and clip to extent of grid (defined by flowdir raster)
|
|
563
|
+
with rasterio.open(self.vars['land_use']['path']) as rs:
|
|
564
|
+
out_img, out_transform = mask(rs, [self.grid_bbox], crop=True, filled=False)
|
|
565
|
+
src_arr = out_img[0]
|
|
566
|
+
|
|
567
|
+
# Prepare a dict of lists (empty for the moment) to store arrays to be summed
|
|
568
|
+
# to create final high resolution array to downsample
|
|
569
|
+
nf_cat_arrs = {}
|
|
570
|
+
for nf_cat in nf_cats:
|
|
571
|
+
nf_cat_arrs.update({nf_cat: []})
|
|
572
|
+
|
|
573
|
+
# Loop through CLC categories and create NF cat rasters from them
|
|
574
|
+
for key, conv in cat_conv_dict.items():
|
|
575
|
+
# CLC "boolean" (1/0) raster for this category
|
|
576
|
+
src_cat_rs = np.where(src_arr == int(key), float(1), float(0))
|
|
577
|
+
# Get name of NF cat and fraction of this CLC cat contributing to it - tuple if not?
|
|
578
|
+
for nf_cat in conv:
|
|
579
|
+
if type(nf_cat) is list:
|
|
580
|
+
nf_cat_name = nf_cat[0]
|
|
581
|
+
frac_contr_to_nf_cat = float(nf_cat[1])
|
|
582
|
+
else:
|
|
583
|
+
nf_cat_name = nf_cat
|
|
584
|
+
frac_contr_to_nf_cat = 1
|
|
585
|
+
|
|
586
|
+
# Add this CLC cat, multiplied by the fraction of it contributing to
|
|
587
|
+
# the NF cat, to the list of CLC rasters to be combined into this NF cat
|
|
588
|
+
nf_cat_arrs[nf_cat_name].append(src_cat_rs * frac_contr_to_nf_cat)
|
|
589
|
+
|
|
590
|
+
# Sum all the contributors to each NF cat
|
|
591
|
+
nf_final_arrs = {name: np.sum(nf_cat_arr, axis=0) for name, nf_cat_arr in nf_cat_arrs.items()}
|
|
592
|
+
|
|
593
|
+
# Reproject the higher res NF cat to NF model res (defined by grid rs),
|
|
594
|
+
# using the average resampling method to get the fraction cover for each cell.
|
|
595
|
+
# Store output in cats array to fill final raster file (one NF cat per band).
|
|
596
|
+
cats = {}
|
|
597
|
+
for name, old_arr in nf_final_arrs.items():
|
|
598
|
+
if old_arr.shape == src_arr.shape:
|
|
599
|
+
# Re-read flowdir as reproject fills new_arr
|
|
600
|
+
new_arr = self.grid.read(1, masked=True)
|
|
601
|
+
# Reproject. Remember we're not converting CRS here, so clc_rs
|
|
602
|
+
# CRS can be used as src and dst
|
|
603
|
+
reproject(
|
|
604
|
+
source=old_arr,
|
|
605
|
+
destination=new_arr,
|
|
606
|
+
src_transform=rs.transform,
|
|
607
|
+
dst_transform=self.grid.transform,
|
|
608
|
+
src_crs=rs.crs,
|
|
609
|
+
dst_crs=rs.crs,
|
|
610
|
+
resampling=Resampling.average
|
|
611
|
+
)
|
|
612
|
+
new_arr = np.ma.masked_where(self.grid_mask, new_arr) # Reclip the array
|
|
613
|
+
cats[name] = new_arr # Store this cat
|
|
614
|
+
|
|
615
|
+
# Create the NetCDF variable, set cat names in attribute (in order of land use cat dimension)
|
|
616
|
+
# and fill the variable. Loop through cats manually as fill values not set properly when
|
|
617
|
+
# filling entire variable at once
|
|
618
|
+
nc_var = self.setup_netcdf_var('land_use', [(cat_dim, len(cats))])
|
|
619
|
+
nc_var.cat_names = list(cats.keys())
|
|
620
|
+
for i, (cat_name, cat) in enumerate(cats.items()):
|
|
621
|
+
nc_var[i, :, :] = cat
|
|
622
|
+
|
|
623
|
+
def routing(self):
|
|
624
|
+
"""Use the flow direction to route the waterbody network."""
|
|
625
|
+
# Create the empty arrays to begin with a mask ready to be filled
|
|
626
|
+
router = Router(self.flow_dir) # Set up the router to deal with common routing tasks
|
|
627
|
+
outflow_arr = np.ma.zeros((*self.flow_dir.shape, 2), dtype=np.dtype('i2'))
|
|
628
|
+
outflow_arr.mask = self.grid_mask # Set the grid mask
|
|
629
|
+
inflows_arr = np.ma.array(np.ma.empty((*self.flow_dir.shape, 7, 2), dtype=np.dtype('i2')), mask=True) # Max of seven inflows
|
|
630
|
+
n_waterbodies = np.ma.array(np.ma.empty(self.flow_dir.shape, dtype=np.dtype('i2')), mask=True)
|
|
631
|
+
is_headwater = np.ma.array(np.ma.empty(self.flow_dir.shape, dtype=np.dtype('u1')), mask=True)
|
|
632
|
+
|
|
633
|
+
# Use the flow direction to set outflow and inflows to each cell
|
|
634
|
+
for index, cell in np.ndenumerate(self.flow_dir):
|
|
635
|
+
y, x = index[0] + 1, index[1] + 1
|
|
636
|
+
if not self.grid_mask[index]: # Only for non-masked elements
|
|
637
|
+
outflow_arr[index] = router.outflow_from_flow_dir(x, y)
|
|
638
|
+
inflows_arr[index] = router.inflows_from_flow_dir(x, y)
|
|
639
|
+
n_waterbodies[index], is_headwater[index]\
|
|
640
|
+
= router.n_waterbodies_from_inflows(x, y, outflow_arr[index], inflows_arr[index])
|
|
641
|
+
|
|
642
|
+
# Create NetCDF vars for these arrays. Firstly, outflow
|
|
643
|
+
nc_var = self.nc.createVariable('outflow', np.dtype('i2'), ('y', 'x', 'd'))
|
|
644
|
+
nc_var.long_name = 'index of grid cell outflow'
|
|
645
|
+
nc_var.units = ''
|
|
646
|
+
nc_var.grid_mapping = 'crs'
|
|
647
|
+
nc_var[:] = outflow_arr
|
|
648
|
+
|
|
649
|
+
# Inflows
|
|
650
|
+
nc_var = self.nc.createVariable('inflows', np.dtype('i2'), ('y', 'x', 'w', 'd'))
|
|
651
|
+
nc_var.long_name = 'indices of grid cell inflows'
|
|
652
|
+
nc_var.units = ''
|
|
653
|
+
nc_var.grid_mapping = 'crs'
|
|
654
|
+
nc_var[:] = inflows_arr
|
|
655
|
+
|
|
656
|
+
# Number of waterbodies per cell
|
|
657
|
+
nc_var = self.nc.createVariable('n_waterbodies', np.dtype('i2'), ('y', 'x'))
|
|
658
|
+
nc_var.long_name = 'number of waterbodies in grid cell'
|
|
659
|
+
nc_var.units = ''
|
|
660
|
+
nc_var.grid_mapping = 'crs'
|
|
661
|
+
nc_var[:] = n_waterbodies
|
|
662
|
+
|
|
663
|
+
# Is cell a headwater?
|
|
664
|
+
nc_var = self.nc.createVariable('is_headwater', np.dtype('u1'), ('y', 'x'))
|
|
665
|
+
nc_var.long_name = 'is this cell a headwater?'
|
|
666
|
+
nc_var.units = ''
|
|
667
|
+
nc_var.grid_mapping = 'crs'
|
|
668
|
+
nc_var[:] = is_headwater
|
|
669
|
+
|
|
670
|
+
def in_model_domain(self, point):
|
|
671
|
+
"""Check if a point is in the model domain."""
|
|
672
|
+
if (point.x >= self.grid.bounds.left)\
|
|
673
|
+
and (point.x < self.grid.bounds.right)\
|
|
674
|
+
and (point.y > self.grid.bounds.bottom)\
|
|
675
|
+
and (point.y <= self.grid.bounds.top):
|
|
676
|
+
return True
|
|
677
|
+
else:
|
|
678
|
+
return False
|
|
679
|
+
|
|
680
|
+
@staticmethod
|
|
681
|
+
def create_constants(constants_yaml, output_path):
|
|
682
|
+
"""Method to just create a constants file, bypassing
|
|
683
|
+
all the NetCDF setup."""
|
|
684
|
+
yaml = YAML(typ='safe')
|
|
685
|
+
# If no output_path provided, create in same place and same name as YAML
|
|
686
|
+
if output_path is None:
|
|
687
|
+
output_path = f'{os.path.splitext(constants_yaml)[0]}.nml'
|
|
688
|
+
# Open the YAML constants file and read contents
|
|
689
|
+
with open(constants_yaml, 'r') as constants_file:
|
|
690
|
+
constants = yaml.load(constants_file)
|
|
691
|
+
# Open the NML constants file and write
|
|
692
|
+
with open(output_path, 'w') as nml_file:
|
|
693
|
+
allocatable_array_sizes = {}
|
|
694
|
+
for grp in constants.values():
|
|
695
|
+
for k, v in grp.items():
|
|
696
|
+
if isinstance(v, list):
|
|
697
|
+
allocatable_array_sizes['n_{0}'.format(k)] = len(v)
|
|
698
|
+
f90nml.write({'allocatable_array_sizes' : allocatable_array_sizes}, nml_file)
|
|
699
|
+
f90nml.write(constants, nml_file)
|
|
700
|
+
print(f'Done! Constants file saved to {output_path}')
|