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.
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}')