nfdata 0.2.1__tar.gz → 0.3.1__tar.gz
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-0.2.1 → nfdata-0.3.1}/PKG-INFO +6 -2
- {nfdata-0.2.1 → nfdata-0.3.1}/config.create.example.yaml +28 -6
- {nfdata-0.2.1 → nfdata-0.3.1}/pyproject.toml +6 -1
- nfdata-0.3.1/src/nfdata/_errors.py +11 -0
- nfdata-0.3.1/src/nfdata/_utility.py +52 -0
- {nfdata-0.2.1 → nfdata-0.3.1}/src/nfdata/compiler.py +194 -94
- {nfdata-0.2.1 → nfdata-0.3.1}/src/nfdata/console.py +5 -3
- nfdata-0.3.1/src/nfdata/routing.py +292 -0
- nfdata-0.2.1/src/nfdata/router.py +0 -111
- {nfdata-0.2.1 → nfdata-0.3.1}/LICENSE +0 -0
- {nfdata-0.2.1 → nfdata-0.3.1}/README.md +0 -0
- {nfdata-0.2.1 → nfdata-0.3.1}/config.edit.example.yaml +0 -0
- {nfdata-0.2.1 → nfdata-0.3.1}/environment.yaml +0 -0
- {nfdata-0.2.1 → nfdata-0.3.1}/src/nfdata/__init__.py +0 -0
- {nfdata-0.2.1 → nfdata-0.3.1}/src/nfdata/land_use.default.yaml +0 -0
- {nfdata-0.2.1 → nfdata-0.3.1}/src/nfdata/model_vars.yaml +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: nfdata
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Compile and edit input data for the NanoFASE model
|
|
5
5
|
License: BSD-3-Clause
|
|
6
|
+
License-File: LICENSE
|
|
6
7
|
Author: Sam Harrison
|
|
7
8
|
Author-email: samharrison.xg@gmail.com
|
|
8
9
|
Requires-Python: >=3.10,<4.0
|
|
@@ -12,6 +13,8 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
12
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
15
18
|
Requires-Dist: Pint (>=0.24.1,<0.25.0)
|
|
16
19
|
Requires-Dist: f90nml (>=1.4.4,<2.0.0)
|
|
17
20
|
Requires-Dist: geopandas (>=1.0.0,<2.0.0)
|
|
@@ -21,6 +24,7 @@ Requires-Dist: pandas (>=2.2.2,<3.0.0)
|
|
|
21
24
|
Requires-Dist: rasterio (>=1.3.10,<2.0.0)
|
|
22
25
|
Requires-Dist: ruamel.yaml (>=0.18.6,<0.19.0)
|
|
23
26
|
Requires-Dist: shapely (>=2.0.4,<3.0.0)
|
|
27
|
+
Requires-Dist: whitebox (>=2.3.6,<3.0.0)
|
|
24
28
|
Project-URL: Documentation, https://nerc-ceh.github.io/nanofase
|
|
25
29
|
Description-Content-Type: text/markdown
|
|
26
30
|
|
|
@@ -11,16 +11,38 @@ time:
|
|
|
11
11
|
n: 365 # Number of timesteps to run the model for
|
|
12
12
|
dt: 86400 # Length of each timestep in seconds
|
|
13
13
|
start_date: 2015-01-01 # Start date for the model run
|
|
14
|
+
# If the `condition_dem` config option is present, the compiler will attempt to remove
|
|
15
|
+
# pits, depressions and flat areas from the DEM using WhiteboxTools'
|
|
16
|
+
# `breach_depressions_least_cost`.
|
|
17
|
+
condition_dem:
|
|
18
|
+
# If `save_dem_to_path` is set, the conditioned DEM will be saved to this path
|
|
19
|
+
# (even if the conditioning hasn't fully worked). If not, a temporary directory
|
|
20
|
+
# is used and the resulting DEM is deleted after the processing.
|
|
21
|
+
save_dem_to_path: ./dem.tif
|
|
22
|
+
# Additional config options specified here will be passed to `breach_depressions_least_cost`
|
|
23
|
+
# See https://www.whiteboxgeo.com/manual/wbt_book/available_tools/hydrological_analysis.html#BreachDepressionsLeastCost
|
|
24
|
+
# If a value for `callback` is provided, it is ignored as we implement our own callback to
|
|
25
|
+
# deal with errors. The only required (by Whitebox) parameter is `dist`, which specifies
|
|
26
|
+
# the maximum search distance for breach paths in cells. The default value used in nfdata is 10000.
|
|
27
|
+
dist: 10000
|
|
14
28
|
|
|
15
29
|
# Meteorological and hydrological
|
|
16
|
-
|
|
17
|
-
|
|
30
|
+
# -------------------------------
|
|
31
|
+
# The model requires either a flow direction or DEM raster. If no flow direction
|
|
32
|
+
# is provided, the DEM will be used to calculate flow direction. If both are provided,
|
|
33
|
+
# the flow direction will be used. The DEM must either be conditioned (contain no pits,
|
|
34
|
+
# depressions or flat areas), or if the config option `condition_dem` is set to `True`,
|
|
35
|
+
# the compiler will attempt to condition the DEM using WhiteboxTools'
|
|
36
|
+
# `breach_depressions_least_cost`. This is not guaranteed to work, and therefore users
|
|
37
|
+
# might wish to condition the DEM themselves.
|
|
38
|
+
# `flow_dir` and `dem` must be rasters that are openable by Rasterio.
|
|
39
|
+
flow_dir:
|
|
18
40
|
path: <root_dir>flow_dir.tif # Path to file, <root_dir> variable can be used from above
|
|
19
41
|
crs: EPSG:27700 # Manually specify CRS if it is absent or ill-defined in file
|
|
20
|
-
dem:
|
|
21
|
-
type: raster
|
|
42
|
+
dem:
|
|
43
|
+
type: raster
|
|
22
44
|
units: dm
|
|
23
|
-
path: <root_dir>dem.
|
|
45
|
+
path: <root_dir>dem.tif
|
|
24
46
|
runoff: # Runoff, usually provided by a hydrological model
|
|
25
47
|
type: csv
|
|
26
48
|
path: <root_dir>runoff.csv
|
|
@@ -41,7 +63,7 @@ precip: # Precipitation
|
|
|
41
63
|
# Soil
|
|
42
64
|
# Most soil properties can be obtained from the European Soil Data Center (ESDAC): https://esdac.jrc.ec.europa.eu/
|
|
43
65
|
soil_bulk_density:
|
|
44
|
-
type: raster
|
|
66
|
+
type: raster # For spatial variables, only rasters are supported at the moment. Spatiotemporal variables can be CSVs
|
|
45
67
|
units: t/m**3
|
|
46
68
|
path: <root_dir>soil_bulk_density.tif
|
|
47
69
|
# Source example:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "nfdata"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
description = "Compile and edit input data for the NanoFASE model"
|
|
5
5
|
authors = ["Sam Harrison <samharrison.xg@gmail.com>"]
|
|
6
6
|
license = "BSD-3-Clause"
|
|
@@ -24,8 +24,13 @@ netcdf4 = "^1.7.1.post1"
|
|
|
24
24
|
shapely = "^2.0.4"
|
|
25
25
|
Pint = "^0.24.1"
|
|
26
26
|
f90nml = "^1.4.4"
|
|
27
|
+
whitebox = "^2.3.6"
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
[tool.poetry.group.dev.dependencies]
|
|
31
|
+
flake8 = "^7.2.0"
|
|
32
|
+
pytest = "^8.3.5"
|
|
33
|
+
|
|
29
34
|
[build-system]
|
|
30
35
|
requires = ["poetry-core"]
|
|
31
36
|
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class RoutingError(Exception):
|
|
2
|
+
"""Exception raised for errors while routing waterbodies."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def whitebox_callback(msg):
|
|
6
|
+
"""
|
|
7
|
+
Callback function to catch Whitebox errors
|
|
8
|
+
"""
|
|
9
|
+
if 'Error' in msg:
|
|
10
|
+
raise RoutingError(f'Whitebox raised an error: '
|
|
11
|
+
f'{msg}')
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility functions for nfdata.
|
|
3
|
+
"""
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, Tuple, Union
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import rasterio
|
|
10
|
+
from affine import Affine
|
|
11
|
+
from rasterio.coords import BoundingBox
|
|
12
|
+
from rasterio.crs import CRS
|
|
13
|
+
from shapely.geometry import Polygon, box
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class RasterInfo:
|
|
18
|
+
bounds: BoundingBox
|
|
19
|
+
crs: CRS
|
|
20
|
+
transform: Affine
|
|
21
|
+
res: Tuple[float, float]
|
|
22
|
+
width: int
|
|
23
|
+
height: int
|
|
24
|
+
dtype: str
|
|
25
|
+
count: int
|
|
26
|
+
shape: tuple
|
|
27
|
+
nodata: Optional[float]
|
|
28
|
+
bbox: Polygon
|
|
29
|
+
arr: Union[np.ndarray, np.ma.MaskedArray]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def extract_raster_info(path: Union[str, Path],
|
|
33
|
+
masked: bool = True) -> RasterInfo:
|
|
34
|
+
"""
|
|
35
|
+
Extract data and info from a raster file and store
|
|
36
|
+
it in a RasterInfo dataclass so that it can be closed
|
|
37
|
+
"""
|
|
38
|
+
with rasterio.open(path) as src:
|
|
39
|
+
return RasterInfo(
|
|
40
|
+
bounds=src.bounds,
|
|
41
|
+
crs=src.crs,
|
|
42
|
+
transform=src.transform,
|
|
43
|
+
res=src.res,
|
|
44
|
+
width=src.width,
|
|
45
|
+
height=src.height,
|
|
46
|
+
dtype=src.dtypes[0],
|
|
47
|
+
count=src.count,
|
|
48
|
+
shape=src.shape,
|
|
49
|
+
nodata=src.nodata,
|
|
50
|
+
bbox=box(*src.bounds),
|
|
51
|
+
arr=src.read(1, masked=masked)
|
|
52
|
+
)
|
|
@@ -1,26 +1,34 @@
|
|
|
1
|
+
import importlib.resources as pkg_resources
|
|
2
|
+
import os
|
|
1
3
|
import shutil
|
|
2
4
|
import sys
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import f90nml
|
|
8
|
+
import geopandas as gpd
|
|
6
9
|
import numpy as np
|
|
7
10
|
import pandas as pd
|
|
8
|
-
import geopandas as gpd
|
|
9
11
|
import rasterio
|
|
12
|
+
from netCDF4 import Dataset
|
|
13
|
+
from pint import UnitRegistry
|
|
10
14
|
from rasterio.crs import CRS
|
|
11
15
|
from rasterio.mask import mask
|
|
12
|
-
from rasterio.warp import
|
|
13
|
-
from shapely.geometry import box
|
|
16
|
+
from rasterio.warp import Resampling, reproject
|
|
14
17
|
from ruamel.yaml import YAML
|
|
15
|
-
from
|
|
16
|
-
|
|
17
|
-
from .
|
|
18
|
+
from shapely.geometry import box
|
|
19
|
+
|
|
20
|
+
from . import routing
|
|
21
|
+
from ._utility import extract_raster_info
|
|
18
22
|
|
|
19
23
|
|
|
20
24
|
class Compiler:
|
|
21
25
|
|
|
22
|
-
def __init__(self, task, config_path, model_vars_path):
|
|
26
|
+
def __init__(self, task, config_path, model_vars_path=None):
|
|
23
27
|
"""Initialise the compiler, either to compile or edit the dataset."""
|
|
28
|
+
# If we haven't been provided a model vars file, use the one in the package
|
|
29
|
+
if model_vars_path is None:
|
|
30
|
+
model_vars_path = pkg_resources.files(__package__)\
|
|
31
|
+
.joinpath('model_vars.yaml')
|
|
24
32
|
# Open the config and model_vars files
|
|
25
33
|
self.yaml = YAML(typ='safe')
|
|
26
34
|
with open(config_path, 'r') as config_file, open(model_vars_path, 'r') as model_vars_file:
|
|
@@ -56,12 +64,15 @@ class Compiler:
|
|
|
56
64
|
self.vars_spatiotemporal.append(k)
|
|
57
65
|
elif ('dims' in v) and (v['dims'] == ['p', 't', 'y', 'x']):
|
|
58
66
|
self.vars_spatial_point.append(k)
|
|
59
|
-
elif ('dims' in v) and
|
|
67
|
+
elif ('dims' in v) and \
|
|
68
|
+
(all(x in v['dims'] for x in ['y', 'x'])) and \
|
|
69
|
+
(len(v['dims']) == 3):
|
|
60
70
|
self.vars_spatial_1d.append(k)
|
|
61
71
|
# Setup the unit registry
|
|
62
72
|
self.ureg = UnitRegistry()
|
|
63
73
|
# Was a root directory specified?
|
|
64
|
-
self.root_dir = self.config['root_dir']
|
|
74
|
+
self.root_dir = self.config['root_dir'] \
|
|
75
|
+
if 'root_dir' in self.config else ''
|
|
65
76
|
# Create empty dict ready to save vars to memory
|
|
66
77
|
self.saved_vars = {}
|
|
67
78
|
# Do we want to compile or edit a dataset?
|
|
@@ -101,22 +112,29 @@ class Compiler:
|
|
|
101
112
|
# Define the timestep as a unit, based on that given in config file
|
|
102
113
|
self.ureg.define('timestep = {0} * second'.format(self.config['time']['dt']))
|
|
103
114
|
# Set grid properties from the NetCDF file
|
|
104
|
-
self.grid =
|
|
115
|
+
self.grid = extract_raster_info(f'netcdf:{self.config["output_nc_file"]}:flow_dir',
|
|
116
|
+
masked=True)
|
|
105
117
|
self.grid_crs = CRS.from_wkt(self.nc['crs'].crs_wkt)
|
|
106
118
|
self.grid_bbox = box(*self.grid.bounds)
|
|
107
|
-
self.grid_mask = np.ma.getmask(self.grid.
|
|
119
|
+
self.grid_mask = np.ma.getmask(self.grid.arr)
|
|
108
120
|
|
|
109
121
|
def create(self):
|
|
110
|
-
"""
|
|
111
|
-
|
|
122
|
+
"""
|
|
123
|
+
Compile the data from multiple input files, specified in the config
|
|
124
|
+
file, to a NetCDF dataset for spatial and/or temporal data, and a
|
|
125
|
+
Fortran namelist file for constants.
|
|
126
|
+
"""
|
|
112
127
|
# Set up the dataset:
|
|
113
|
-
# - Use flow direction
|
|
128
|
+
# - Use flow direction or DEM rasters to give us the grid system (bounds, CRS, etc)
|
|
114
129
|
# - Creating the NetCDF dataset with this grid information
|
|
115
130
|
# - Set tidal bounds in lieu of routing
|
|
116
131
|
# - Route water bodies (e.g. set inflows, outflows and headwaters) using the flow direction and tidal bounds
|
|
117
|
-
print('Setting up dataset...\n'
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
print('Setting up dataset...\n')
|
|
133
|
+
# Set the flow direction either directly from the flow_dir raster,
|
|
134
|
+
# or if not provided, calculated from the dem raster. Also set the
|
|
135
|
+
# grid, CRS and grid bounding box
|
|
136
|
+
self.flow_dir, self.grid = self.parse_flow_dir()
|
|
137
|
+
print('\t...parsing flow_dir')
|
|
120
138
|
print('\t...creating NetCDF file')
|
|
121
139
|
self.setup_netcdf_dataset()
|
|
122
140
|
self.parse_spatial_var('is_estuary', save=True)
|
|
@@ -184,72 +202,127 @@ class Compiler:
|
|
|
184
202
|
= os.path.join(self.config['root_dir'], path.split('<root_dir>')[1])
|
|
185
203
|
|
|
186
204
|
def parse_flow_dir(self):
|
|
187
|
-
"""
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
205
|
+
"""
|
|
206
|
+
Either use the provided flow_dir raster, or if not presented,
|
|
207
|
+
calculate flow direction from the provided digital elevation
|
|
208
|
+
model
|
|
209
|
+
"""
|
|
210
|
+
# If flow_dir has been provided, use it directly. Else, check that
|
|
211
|
+
# a dem has been provided and calculate the flow direction from that.
|
|
212
|
+
# If neither provided, error out
|
|
213
|
+
if 'flow_dir' in self.config:
|
|
214
|
+
# Open and read the flow_dir raster
|
|
215
|
+
flow_dir_path = self.config['flow_dir']['path'] \
|
|
216
|
+
.replace('<root_dir>', self.config['root_dir'])
|
|
217
|
+
grid = extract_raster_info(flow_dir_path, masked=True)
|
|
218
|
+
flow_dir = grid.arr
|
|
219
|
+
# If a CRS has been specified, use this instead of the raster's
|
|
192
220
|
# internal CRS. This is useful if a raster has an ill-defined CRS
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
221
|
+
if 'crs' in self.config['flow_dir']:
|
|
222
|
+
grid.crs = CRS.from_user_input(self.config['flow_dir']['crs'])
|
|
223
|
+
# Set a flag to say we used the flow_dir raster
|
|
224
|
+
var_used = 'flow_dir'
|
|
225
|
+
elif 'dem' in self.config:
|
|
226
|
+
# Path to the DEM
|
|
227
|
+
dem_path = Path(
|
|
228
|
+
self.config['dem']['path'].replace('<root_dir>',
|
|
229
|
+
self.config['root_dir'])
|
|
230
|
+
).resolve()
|
|
231
|
+
# If we have been asked to condition the DEM, do this first
|
|
232
|
+
temp_dir = None
|
|
233
|
+
if 'condition_dem' in self.config:
|
|
234
|
+
dem_path_conditioned, temp_dir = routing.condition_dem(
|
|
235
|
+
dem_path,
|
|
236
|
+
self.config['condition_dem'],
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
# If no conditioning is required, just use the original DEM
|
|
240
|
+
dem_path_conditioned = dem_path
|
|
241
|
+
# Open and read the DEM, unmasked so that the flow direction
|
|
242
|
+
# calculation routine can use the nodata values
|
|
243
|
+
grid = extract_raster_info(dem_path_conditioned, masked=False)
|
|
244
|
+
# Clean the temporary directory if we created one
|
|
245
|
+
if temp_dir is not None:
|
|
246
|
+
temp_dir.cleanup()
|
|
247
|
+
# If a CRS has been specified, use this instead of the raster's
|
|
248
|
+
# internal CRS. This is useful if a raster has an ill-defined CRS
|
|
249
|
+
if 'crs' in self.config['dem']:
|
|
250
|
+
grid.crs = CRS.from_user_input(self.config['dem']['crs'])
|
|
251
|
+
# Calculate the flow direction, which returns a masked array
|
|
252
|
+
flow_dir = routing.calculate_flow_dir(dem=grid.arr,
|
|
253
|
+
res=grid.res,
|
|
254
|
+
nodata=grid.nodata)
|
|
255
|
+
# Set a flag to say we used the dem raster from grid properties
|
|
256
|
+
var_used = 'dem'
|
|
206
257
|
else:
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
258
|
+
# If neither flow_dir or dem have been provided, error out
|
|
259
|
+
raise Exception('Neither `flow_dir` nor `dem` rasters '
|
|
260
|
+
'have been provided.')
|
|
261
|
+
# Only projected rasters allowed for the moment
|
|
262
|
+
if grid.crs.is_geographic:
|
|
263
|
+
raise Exception(f'The `{var_used}` raster must be projected, '
|
|
264
|
+
'not geographic. I got a geographic CRS: '
|
|
265
|
+
f'{grid.crs}')
|
|
266
|
+
return flow_dir, grid
|
|
210
267
|
|
|
211
268
|
def setup_netcdf_dataset(self):
|
|
212
|
-
"""
|
|
213
|
-
|
|
214
|
-
|
|
269
|
+
"""
|
|
270
|
+
Create NetCDF file, add required dimensions, coordinate variables
|
|
271
|
+
and the flow direction variable.
|
|
272
|
+
"""
|
|
273
|
+
self.nc = Dataset(self.config['output']['nc_file'], 'w',
|
|
274
|
+
format='NETCDF4')
|
|
215
275
|
self.nc.title = "Input data for NanoFASE model"
|
|
216
|
-
self.nc.nanomaterial = self.config['nanomaterial']
|
|
276
|
+
self.nc.nanomaterial = self.config['nanomaterial'] \
|
|
277
|
+
if 'nanomaterial' in self.config else 'Unknown'
|
|
217
278
|
self.nc.Conventions = 'CF-1.8'
|
|
218
|
-
|
|
279
|
+
# Setting the coordinates attribute is needed for xarray to
|
|
280
|
+
# distinguish crs as a coordinate rather than a variable
|
|
281
|
+
self.nc.coordinates = 'crs'
|
|
219
282
|
crs_var = self.nc.createVariable('crs', 'i4')
|
|
220
|
-
|
|
221
|
-
crs_var.
|
|
222
|
-
|
|
283
|
+
# QGIS/ArcGIS recognises spatial_ref to define the CRS
|
|
284
|
+
crs_var.spatial_ref = self.grid.crs.to_wkt()
|
|
285
|
+
# Latest CF conventions say crs_wkt can be used
|
|
286
|
+
crs_var.crs_wkt = self.grid.crs.to_wkt()
|
|
287
|
+
# Not standardised, but might be useful instead of having to
|
|
288
|
+
# decipher WKT
|
|
289
|
+
crs_var.epsg_code = self.grid.crs.to_epsg()
|
|
223
290
|
# Time dimensions and coordinate variable
|
|
224
|
-
|
|
291
|
+
_ = self.nc.createDimension('t', None)
|
|
225
292
|
t = self.nc.createVariable('t', 'i4', ('t',))
|
|
226
|
-
t.units = "seconds since {0} 00:00:00"
|
|
293
|
+
t.units = "seconds since {0} 00:00:00" \
|
|
294
|
+
.format(self.config['time']['start_date'])
|
|
227
295
|
t.standard_name = 'time'
|
|
228
296
|
t.calendar = 'gregorian'
|
|
229
|
-
t[:] = [i*int(self.config['time']['dt'])
|
|
297
|
+
t[:] = [i*int(self.config['time']['dt'])
|
|
298
|
+
for i in range(int(self.config['time']['n']))]
|
|
230
299
|
# x dimension and coordinate variable
|
|
231
|
-
|
|
300
|
+
_ = self.nc.createDimension('x', self.grid.width)
|
|
232
301
|
x = self.nc.createVariable('x', 'f4', ('x',))
|
|
233
302
|
x.units = 'm'
|
|
234
303
|
x.standard_name = 'projection_x_coordinate'
|
|
235
304
|
x.axis = 'X'
|
|
236
|
-
x[:] = [self.grid.bounds.left + i * self.grid.res[0]
|
|
305
|
+
x[:] = [self.grid.bounds.left + i * self.grid.res[0]
|
|
306
|
+
+ 0.5 * self.grid.res[0] for i in range(self.grid.width)]
|
|
237
307
|
# y dimension and coordinate variable
|
|
238
|
-
|
|
308
|
+
_ = self.nc.createDimension('y', self.grid.height)
|
|
239
309
|
y = self.nc.createVariable('y', 'f4', ('y',))
|
|
240
310
|
y.units = 'm'
|
|
241
311
|
y.standard_name = 'projection_y_coordinate'
|
|
242
312
|
y.axis = 'Y'
|
|
243
|
-
y[:] = [self.grid.bounds.top - i * self.grid.res[1]
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
313
|
+
y[:] = [self.grid.bounds.top - i * self.grid.res[1]
|
|
314
|
+
- 0.5 * self.grid.res[1] for i in range(self.grid.height)]
|
|
315
|
+
# Other useful dimensions (grid, number of waterbodies, bounding
|
|
316
|
+
# box and point sources)
|
|
317
|
+
_ = self.nc.createDimension('d', 2)
|
|
318
|
+
_ = self.nc.createDimension('w', 7)
|
|
319
|
+
_ = self.nc.createDimension('box', 4)
|
|
320
|
+
_ = self.nc.createDimension('p')
|
|
249
321
|
# Grid properties - shape
|
|
250
322
|
grid_shape = self.nc.createVariable('grid_shape', 'i4', ('d',))
|
|
251
323
|
grid_shape.units = ''
|
|
252
|
-
grid_shape.long_name =
|
|
324
|
+
grid_shape.long_name = \
|
|
325
|
+
'number of grid cells along each (x,y) grid axis'
|
|
253
326
|
grid_shape[:] = self.grid.shape[::-1]
|
|
254
327
|
# Grid resolution
|
|
255
328
|
grid_res = self.nc.createVariable('grid_res', 'f4', ('d',))
|
|
@@ -261,9 +334,8 @@ class Compiler:
|
|
|
261
334
|
grid_bounds.units = ''
|
|
262
335
|
grid_bounds.long_name = 'bounding box of the grid'
|
|
263
336
|
grid_bounds[:] = self.grid.bounds
|
|
264
|
-
#
|
|
265
|
-
|
|
266
|
-
# Use the extent of the flow direction array to create a mask for all other data
|
|
337
|
+
# Use the extent of the flow direction array to create a mask for all
|
|
338
|
+
# other data
|
|
267
339
|
self.grid_mask = np.ma.getmask(self.flow_dir)
|
|
268
340
|
nc_var = self.nc.createVariable('flow_dir', 'i4', ('y', 'x'))
|
|
269
341
|
nc_var.long_name = 'flow direction of water in grid cell'
|
|
@@ -329,7 +401,7 @@ class Compiler:
|
|
|
329
401
|
with rasterio.open(path) as rs:
|
|
330
402
|
out_bounds = rs.bounds
|
|
331
403
|
# Mask clips the raster to the grid bounding box or smaller
|
|
332
|
-
out_img, _ = mask(rs, [self.
|
|
404
|
+
out_img, _ = mask(rs, [self.grid.bbox], crop=True, filled=False)
|
|
333
405
|
# The raster might still be smaller than the grid box, so let's check and
|
|
334
406
|
# construct mask array of the correct size if so
|
|
335
407
|
if out_bounds.left > self.grid.bounds.left or out_bounds.right < self.grid.bounds.right \
|
|
@@ -459,8 +531,11 @@ class Compiler:
|
|
|
459
531
|
# Add this time step to the NetCDF file as a masked array
|
|
460
532
|
nc_var[t-1, :, :] = values.magnitude
|
|
461
533
|
else:
|
|
462
|
-
|
|
463
|
-
|
|
534
|
+
raise Exception(f'Spatiotemporal variable ({var_name}) in '
|
|
535
|
+
'raster format must be provided by one raster '
|
|
536
|
+
'file per time step, with the time step '
|
|
537
|
+
'index denoted by /{t/} in the path.')
|
|
538
|
+
|
|
464
539
|
elif var_dict['type'] == 'csv':
|
|
465
540
|
df = pd.read_csv(var_dict['path'], header=0)
|
|
466
541
|
# Loop through the timesteps and create pivot table to obtain spatial array for each
|
|
@@ -470,8 +545,10 @@ class Compiler:
|
|
|
470
545
|
values = np.ma.masked_where(self.grid_mask, pt.values)
|
|
471
546
|
# Check the pivot table's shape is that of the grid we're using
|
|
472
547
|
if pt.shape != self.grid.shape:
|
|
473
|
-
|
|
474
|
-
|
|
548
|
+
raise Exception(f'Inconsistent shape between {var} csv '
|
|
549
|
+
f'file and overall grid system '
|
|
550
|
+
f'({pt.shape} and {self.grid.shape}). '
|
|
551
|
+
'Check indices are set correctly.')
|
|
475
552
|
# Should the array be clipped?
|
|
476
553
|
if 'clip' in var_dict:
|
|
477
554
|
try:
|
|
@@ -561,7 +638,7 @@ class Compiler:
|
|
|
561
638
|
# Open the supplied land use raster
|
|
562
639
|
# Open the raster and clip to extent of grid (defined by flowdir raster)
|
|
563
640
|
with rasterio.open(self.vars['land_use']['path']) as rs:
|
|
564
|
-
out_img, out_transform = mask(rs, [self.
|
|
641
|
+
out_img, out_transform = mask(rs, [self.grid.bbox], crop=True, filled=False)
|
|
565
642
|
src_arr = out_img[0]
|
|
566
643
|
|
|
567
644
|
# Prepare a dict of lists (empty for the moment) to store arrays to be summed
|
|
@@ -582,11 +659,11 @@ class Compiler:
|
|
|
582
659
|
else:
|
|
583
660
|
nf_cat_name = nf_cat
|
|
584
661
|
frac_contr_to_nf_cat = 1
|
|
585
|
-
|
|
662
|
+
|
|
586
663
|
# Add this CLC cat, multiplied by the fraction of it contributing to
|
|
587
664
|
# the NF cat, to the list of CLC rasters to be combined into this NF cat
|
|
588
665
|
nf_cat_arrs[nf_cat_name].append(src_cat_rs * frac_contr_to_nf_cat)
|
|
589
|
-
|
|
666
|
+
|
|
590
667
|
# Sum all the contributors to each NF cat
|
|
591
668
|
nf_final_arrs = {name: np.sum(nf_cat_arr, axis=0) for name, nf_cat_arr in nf_cat_arrs.items()}
|
|
592
669
|
|
|
@@ -597,7 +674,7 @@ class Compiler:
|
|
|
597
674
|
for name, old_arr in nf_final_arrs.items():
|
|
598
675
|
if old_arr.shape == src_arr.shape:
|
|
599
676
|
# Re-read flowdir as reproject fills new_arr
|
|
600
|
-
new_arr = self.grid.
|
|
677
|
+
new_arr = np.empty(self.grid.shape, dtype=src_arr.dtype)
|
|
601
678
|
# Reproject. Remember we're not converting CRS here, so clc_rs
|
|
602
679
|
# CRS can be used as src and dst
|
|
603
680
|
reproject(
|
|
@@ -605,7 +682,7 @@ class Compiler:
|
|
|
605
682
|
destination=new_arr,
|
|
606
683
|
src_transform=rs.transform,
|
|
607
684
|
dst_transform=self.grid.transform,
|
|
608
|
-
src_crs=rs.crs,
|
|
685
|
+
src_crs=rs.crs,
|
|
609
686
|
dst_crs=rs.crs,
|
|
610
687
|
resampling=Resampling.average
|
|
611
688
|
)
|
|
@@ -623,52 +700,75 @@ class Compiler:
|
|
|
623
700
|
def routing(self):
|
|
624
701
|
"""Use the flow direction to route the waterbody network."""
|
|
625
702
|
# Create the empty arrays to begin with a mask ready to be filled
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
703
|
+
outflow_arr = np.ma.zeros((*self.flow_dir.shape, 2),
|
|
704
|
+
dtype=np.dtype('i2'))
|
|
705
|
+
# Set the grid mask
|
|
706
|
+
outflow_arr.mask = self.grid_mask
|
|
707
|
+
# Create the relevant empty arrays
|
|
708
|
+
inflows_arr = np.ma.array(np.ma.empty((*self.flow_dir.shape, 7, 2),
|
|
709
|
+
dtype=np.dtype('i2')),
|
|
710
|
+
mask=True)
|
|
711
|
+
n_waterbodies = np.ma.array(np.ma.empty(self.flow_dir.shape,
|
|
712
|
+
dtype=np.dtype('i2')),
|
|
713
|
+
mask=True)
|
|
714
|
+
is_headwater = np.ma.array(np.ma.empty(self.flow_dir.shape,
|
|
715
|
+
dtype=np.dtype('u1')),
|
|
716
|
+
mask=True)
|
|
632
717
|
|
|
633
718
|
# Use the flow direction to set outflow and inflows to each cell
|
|
634
|
-
for index,
|
|
719
|
+
for index, _ in np.ndenumerate(self.flow_dir):
|
|
635
720
|
y, x = index[0] + 1, index[1] + 1
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
721
|
+
# Only for non-masked elements
|
|
722
|
+
if not self.grid_mask[index]:
|
|
723
|
+
outflow_arr[index] = \
|
|
724
|
+
routing.outflow_from_flow_dir(self.flow_dir, x, y)
|
|
725
|
+
inflows_arr[index] = \
|
|
726
|
+
routing.inflows_from_flow_dir(self.flow_dir, x, y)
|
|
727
|
+
n_waterbodies[index], is_headwater[index] = \
|
|
728
|
+
routing.n_waterbodies_from_inflows(self.flow_dir,
|
|
729
|
+
outflow_arr[index],
|
|
730
|
+
inflows_arr[index])
|
|
731
|
+
|
|
642
732
|
# Create NetCDF vars for these arrays. Firstly, outflow
|
|
643
|
-
nc_var = self.nc.createVariable('outflow',
|
|
733
|
+
nc_var = self.nc.createVariable('outflow',
|
|
734
|
+
np.dtype('i2'),
|
|
735
|
+
('y', 'x', 'd'))
|
|
644
736
|
nc_var.long_name = 'index of grid cell outflow'
|
|
645
737
|
nc_var.units = ''
|
|
646
738
|
nc_var.grid_mapping = 'crs'
|
|
647
739
|
nc_var[:] = outflow_arr
|
|
648
740
|
|
|
649
741
|
# Inflows
|
|
650
|
-
nc_var = self.nc.createVariable('inflows',
|
|
742
|
+
nc_var = self.nc.createVariable('inflows',
|
|
743
|
+
np.dtype('i2'),
|
|
744
|
+
('y', 'x', 'w', 'd'))
|
|
651
745
|
nc_var.long_name = 'indices of grid cell inflows'
|
|
652
746
|
nc_var.units = ''
|
|
653
747
|
nc_var.grid_mapping = 'crs'
|
|
654
748
|
nc_var[:] = inflows_arr
|
|
655
749
|
|
|
656
750
|
# Number of waterbodies per cell
|
|
657
|
-
nc_var = self.nc.createVariable('n_waterbodies',
|
|
751
|
+
nc_var = self.nc.createVariable('n_waterbodies',
|
|
752
|
+
np.dtype('i2'),
|
|
753
|
+
('y', 'x'))
|
|
658
754
|
nc_var.long_name = 'number of waterbodies in grid cell'
|
|
659
755
|
nc_var.units = ''
|
|
660
756
|
nc_var.grid_mapping = 'crs'
|
|
661
757
|
nc_var[:] = n_waterbodies
|
|
662
758
|
|
|
663
759
|
# Is cell a headwater?
|
|
664
|
-
nc_var = self.nc.createVariable('is_headwater',
|
|
760
|
+
nc_var = self.nc.createVariable('is_headwater',
|
|
761
|
+
np.dtype('u1'),
|
|
762
|
+
('y', 'x'))
|
|
665
763
|
nc_var.long_name = 'is this cell a headwater?'
|
|
666
764
|
nc_var.units = ''
|
|
667
765
|
nc_var.grid_mapping = 'crs'
|
|
668
766
|
nc_var[:] = is_headwater
|
|
669
767
|
|
|
670
768
|
def in_model_domain(self, point):
|
|
671
|
-
"""
|
|
769
|
+
"""
|
|
770
|
+
Check if a point is in the model domain.
|
|
771
|
+
"""
|
|
672
772
|
if (point.x >= self.grid.bounds.left)\
|
|
673
773
|
and (point.x < self.grid.bounds.right)\
|
|
674
774
|
and (point.y > self.grid.bounds.bottom)\
|
|
@@ -676,7 +776,7 @@ class Compiler:
|
|
|
676
776
|
return True
|
|
677
777
|
else:
|
|
678
778
|
return False
|
|
679
|
-
|
|
779
|
+
|
|
680
780
|
@staticmethod
|
|
681
781
|
def create_constants(constants_yaml, output_path):
|
|
682
782
|
"""Method to just create a constants file, bypassing
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env python
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
"""
|
|
3
|
+
NanoFASE data is responsible for compiling and editing data for use in
|
|
4
|
+
the NanoFASE model.
|
|
5
|
+
"""
|
|
5
6
|
import argparse
|
|
6
7
|
import importlib.resources as pkg_resources
|
|
8
|
+
|
|
7
9
|
from .compiler import Compiler
|
|
8
10
|
|
|
9
11
|
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities relating to routing water bodies, such as creating
|
|
3
|
+
a flow direction grid from a digital elevation model
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from whitebox.whitebox_tools import WhiteboxTools
|
|
12
|
+
|
|
13
|
+
from ._errors import RoutingError, whitebox_callback
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def in_model_domain(flow_dir, i, j):
|
|
17
|
+
"""
|
|
18
|
+
Check if index [j,i] is in the model domain.
|
|
19
|
+
"""
|
|
20
|
+
i_in_domain = 0 <= i < flow_dir.mask.shape[1]
|
|
21
|
+
j_in_domain = 0 <= j < flow_dir.mask.shape[0]
|
|
22
|
+
not_masked = flow_dir[j, i] is not np.ma.masked \
|
|
23
|
+
if (i_in_domain and j_in_domain) else False
|
|
24
|
+
return i_in_domain and j_in_domain and not_masked
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def outflow_from_flow_dir(flow_dir, x, y):
|
|
28
|
+
"""
|
|
29
|
+
Get the outflow cell reference given the current cell
|
|
30
|
+
reference and a flow direction.
|
|
31
|
+
"""
|
|
32
|
+
xy_out = {
|
|
33
|
+
1: [x+1, y],
|
|
34
|
+
2: [x+1, y+1],
|
|
35
|
+
4: [x, y+1],
|
|
36
|
+
8: [x-1, y+1],
|
|
37
|
+
16: [x-1, y],
|
|
38
|
+
32: [x-1, y-1],
|
|
39
|
+
64: [x, y-1],
|
|
40
|
+
128: [x+1, y-1]
|
|
41
|
+
}
|
|
42
|
+
return xy_out[flow_dir[y-1, x-1]]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def inflows_from_flow_dir(flow_dir, x, y):
|
|
46
|
+
"""Get the inflow cell references given the current cell
|
|
47
|
+
reference and a flow direction."""
|
|
48
|
+
j, i = y - 1, x - 1
|
|
49
|
+
# Flow direction that means the cell with indices inflows[j_n-j, i_n-i]
|
|
50
|
+
# flows into this cell
|
|
51
|
+
inflow_flow_dir = {
|
|
52
|
+
(-1, -1): 2,
|
|
53
|
+
(0, -1): 1,
|
|
54
|
+
(1, -1): 128,
|
|
55
|
+
(-1, 0): 4,
|
|
56
|
+
(0, 0): 0,
|
|
57
|
+
(1, 0): 64,
|
|
58
|
+
(-1, 1): 8,
|
|
59
|
+
(0, 1): 16,
|
|
60
|
+
(1, 1): 32
|
|
61
|
+
}
|
|
62
|
+
inflow_cells = []
|
|
63
|
+
# Loop through the neighbours and check if they're inflows to this cell
|
|
64
|
+
for j_n in range(j-1, j+2):
|
|
65
|
+
for i_n in range(i-1, i+2):
|
|
66
|
+
if in_model_domain(flow_dir, i_n, j_n):
|
|
67
|
+
if flow_dir[j_n, i_n] == inflow_flow_dir[(j_n-j, i_n-i)]:
|
|
68
|
+
inflow_cells.append([i_n+1, j_n+1])
|
|
69
|
+
# Create masked array from the inflow_cells list
|
|
70
|
+
inflow_cells_ma = np.ma.array(np.ma.empty((7, 2), dtype=int), mask=True)
|
|
71
|
+
if len(inflow_cells) > 0: # Only fill if there are inflows
|
|
72
|
+
inflow_cells_ma[0:len(inflow_cells)] = inflow_cells
|
|
73
|
+
return inflow_cells_ma
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def n_waterbodies_from_inflows(flow_dir, outflow, inflows):
|
|
77
|
+
"""
|
|
78
|
+
Calculate the number of waterbodies from the inflows to the cell.
|
|
79
|
+
"""
|
|
80
|
+
j_out, i_out = outflow[1] - 1, outflow[0] - 1
|
|
81
|
+
# Count the unmasked elements to get n_inflows
|
|
82
|
+
n_inflows = inflows.count(axis=0)[0]
|
|
83
|
+
# If there are no inflows but the outflow is to the model domain, it
|
|
84
|
+
# must be a headwater. Else, number of waterbodies is same as number of
|
|
85
|
+
# inflows
|
|
86
|
+
if n_inflows == 0 and in_model_domain(flow_dir, i_out, j_out):
|
|
87
|
+
n_waterbodies = 1
|
|
88
|
+
is_headwater = 1
|
|
89
|
+
else:
|
|
90
|
+
n_waterbodies = n_inflows
|
|
91
|
+
is_headwater = 0
|
|
92
|
+
return n_waterbodies, is_headwater
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def calculate_flow_dir(dem,
|
|
96
|
+
res,
|
|
97
|
+
nodata: Optional[float] = np.nan,
|
|
98
|
+
treat_nodata_as_drain=True,
|
|
99
|
+
attempt_to_resolve=False,
|
|
100
|
+
lonely_cell_flowdir=1,
|
|
101
|
+
error_if_unresolved=True):
|
|
102
|
+
"""
|
|
103
|
+
Calculate the D8 flow direction from a digital elevation model
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
dem : array-like with dtype=float
|
|
108
|
+
The digital elevation model to calculate flow direction from
|
|
109
|
+
res : length-2 tuple
|
|
110
|
+
The (y, x) resolution of the DEM, used to calculate distances
|
|
111
|
+
for non-square grid cells
|
|
112
|
+
nodata : float, default=np.nan
|
|
113
|
+
The value in `dem` that should be treated as nodata. Defaults
|
|
114
|
+
to NaN
|
|
115
|
+
treat_nodata_as_drain : bool, default=True
|
|
116
|
+
Should nodata cells be treated as a drain, i.e. with lower
|
|
117
|
+
elevation than all other cells? If so, this forces the flow
|
|
118
|
+
direction towards nodata cells by setting their elevation to
|
|
119
|
+
-9999. This is particularly useful where nodata represents
|
|
120
|
+
waterbodies. The `dem` is padded with one pixel of nodata on
|
|
121
|
+
each side in order to calculate flow directions from edge cells,
|
|
122
|
+
and so this flag also affects whether water drains from `dem`
|
|
123
|
+
boundaries. If this is set to False, you may end up with
|
|
124
|
+
unresolved cells
|
|
125
|
+
attempt_to_resolve : bool, default=False
|
|
126
|
+
If the resulting flow direction raster has unresolved cells
|
|
127
|
+
(flow direction = 0), setting `attempt_to_resolve` to True uses
|
|
128
|
+
same flow direction as that from the neighbouring cell with the
|
|
129
|
+
steepest sloe to this cell, effectively presuming the slope carries
|
|
130
|
+
on in the same direction. Warning: this might result in
|
|
131
|
+
neighbouring cells that flow into each other, effectively creating
|
|
132
|
+
"pools" from which flows can't drain
|
|
133
|
+
lonely_cell_flowdir : int or np.nan, default=1
|
|
134
|
+
What flow direction to assign to individual cells surrounded by
|
|
135
|
+
nodata. By default, flow direction is assumed to be eastwards (1).
|
|
136
|
+
If `nodata` or `np.nan`, the cell is removed from the output
|
|
137
|
+
raster. Note that setting to 0 and specifying
|
|
138
|
+
`error_if_unresolved=True` will result in an error if your `dem`
|
|
139
|
+
has lonely cells. There are no further checks on the validity of
|
|
140
|
+
this value, so you can use this to flag lonely cells by setting to
|
|
141
|
+
an arbitrary value
|
|
142
|
+
error_if_unresolved : bool, default=True
|
|
143
|
+
Whether to raise an error if unresolved (flow direction = 0) cells
|
|
144
|
+
are encountered
|
|
145
|
+
|
|
146
|
+
Returns
|
|
147
|
+
-------
|
|
148
|
+
np.ma.masked_array(np.int16)
|
|
149
|
+
Masked flow direction array
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
# Mappings between D8 codes and directions
|
|
153
|
+
directions = np.array([
|
|
154
|
+
(-1, 1), # 128 - NE
|
|
155
|
+
(-1, 0), # 64 - N
|
|
156
|
+
(-1, -1), # 32 - NW
|
|
157
|
+
(0, -1), # 16 - W
|
|
158
|
+
(1, -1), # 8 - SW
|
|
159
|
+
(1, 0), # 4 - S
|
|
160
|
+
(1, 1), # 2 - SE
|
|
161
|
+
(0, 1), # 1 - E
|
|
162
|
+
])
|
|
163
|
+
d8_codes = np.array([128, 64, 32, 16, 8, 4, 2, 1])
|
|
164
|
+
d8_codes_opposite = np.roll(d8_codes, 4)
|
|
165
|
+
# To make processing easier, we convert nodata values to NaNs for
|
|
166
|
+
# the DEM and -32768 for the flowdir array
|
|
167
|
+
dem = np.where(dem == nodata, np.nan, dem)
|
|
168
|
+
if (lonely_cell_flowdir == nodata) or np.isnan(lonely_cell_flowdir):
|
|
169
|
+
lonely_cell_flowdir = -32768
|
|
170
|
+
# In case there are data right to the edges, we pad with one pixel
|
|
171
|
+
# of NaNs
|
|
172
|
+
dem_padded = np.pad(dem, ((1, 1), (1, 1)), constant_values=np.nan)
|
|
173
|
+
# Get the number of (padded) rows and cols
|
|
174
|
+
nrows, ncols = dem_padded.shape
|
|
175
|
+
# Create a new array to store the (unpadded) flowdir in
|
|
176
|
+
flow_dir = np.full_like(dem, -32768, dtype=np.int16)
|
|
177
|
+
# If `treat_nodata_as_drain` is True, we presume that nodata cells
|
|
178
|
+
# (including the padded region) are water and should be at some
|
|
179
|
+
# negative elevation to force flow to drain into them. If False,
|
|
180
|
+
# they are simply ignored in the flow direction calculation
|
|
181
|
+
dem_adj = np.nan_to_num(dem_padded, nan=-np.inf) \
|
|
182
|
+
if treat_nodata_as_drain else dem_padded
|
|
183
|
+
|
|
184
|
+
# Loop over the cells (except the pad)
|
|
185
|
+
for row in range(1, nrows - 1):
|
|
186
|
+
for col in range(1, ncols - 1):
|
|
187
|
+
# Get the value for this cell
|
|
188
|
+
this_elevation = dem_padded[row, col]
|
|
189
|
+
# If this cell is nodata, then we're not interested in the flow
|
|
190
|
+
# direction
|
|
191
|
+
if np.isnan(this_elevation):
|
|
192
|
+
continue
|
|
193
|
+
# Init vars to be update in the loop through neighbouring cells
|
|
194
|
+
max_slope = -np.inf
|
|
195
|
+
min_slope = 0
|
|
196
|
+
heighest_neighbour_direction = 0
|
|
197
|
+
best_direction = 0
|
|
198
|
+
lonely_cell = True
|
|
199
|
+
# Loop through neighbouring cells
|
|
200
|
+
for i, (dr, dc) in enumerate(directions):
|
|
201
|
+
# Get the elevation of this neighbouring cell
|
|
202
|
+
neighbour_elevation = dem_adj[row + dr, col + dc]
|
|
203
|
+
# If the neighbouring cell is nodata and we're not treating
|
|
204
|
+
# nodata as drains, then ignore the influence of this cell
|
|
205
|
+
if np.isnan(neighbour_elevation):
|
|
206
|
+
continue
|
|
207
|
+
# If neighbouring cell has data, then set the lonely_cell
|
|
208
|
+
# flag to False to say the central cell isn't a single cell
|
|
209
|
+
# surrounded by nodata (including nodata cells treated as
|
|
210
|
+
# drains)
|
|
211
|
+
if neighbour_elevation != -np.inf:
|
|
212
|
+
lonely_cell = False
|
|
213
|
+
# Difference in elevation and slope
|
|
214
|
+
dz = this_elevation - neighbour_elevation
|
|
215
|
+
distance = np.sqrt((res[0]*dr)**2 + (res[1]*dc)**2)
|
|
216
|
+
slope = dz / distance
|
|
217
|
+
# If slope is steeper than the current maximum, and
|
|
218
|
+
# it's positive (flow *away* from this cell), then
|
|
219
|
+
# set this as the best direction
|
|
220
|
+
if (slope > max_slope) and (slope > 0.0):
|
|
221
|
+
max_slope = slope
|
|
222
|
+
best_direction = d8_codes[i]
|
|
223
|
+
elif (slope < min_slope):
|
|
224
|
+
# If it is a negative slope (this neighbour is higher
|
|
225
|
+
# than the central cell), store this in case we need it
|
|
226
|
+
# to try and resolve cells later
|
|
227
|
+
min_slope = slope
|
|
228
|
+
heighest_neighbour_direction = d8_codes_opposite[i]
|
|
229
|
+
|
|
230
|
+
# Fill the flow direction array. If no candidate has been
|
|
231
|
+
# found then the best_direction is 0 and the cell is unresolved
|
|
232
|
+
flow_dir_ = best_direction
|
|
233
|
+
|
|
234
|
+
# If this is a lonely cell, set it to the specified value
|
|
235
|
+
if lonely_cell:
|
|
236
|
+
flow_dir_ = lonely_cell_flowdir
|
|
237
|
+
# If we're meant to, try and resolve this cell and raise an
|
|
238
|
+
# error for unresolved cells
|
|
239
|
+
if (flow_dir_ == 0):
|
|
240
|
+
if attempt_to_resolve:
|
|
241
|
+
flow_dir_ = heighest_neighbour_direction
|
|
242
|
+
if error_if_unresolved:
|
|
243
|
+
neighbouring_dem = dem_adj[row-1:row+2, col-1:col+2]
|
|
244
|
+
raise RoutingError(
|
|
245
|
+
f"Could not resolve flow direction for cell ({col-1}, {row-1}). "
|
|
246
|
+
f"DEM for surrounding cells:\n{neighbouring_dem}"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Set the best value for the flow direction
|
|
250
|
+
flow_dir[row - 1, col - 1] = flow_dir_
|
|
251
|
+
|
|
252
|
+
# Return the flow direction array as a masked array
|
|
253
|
+
return np.ma.masked_equal(flow_dir, -32768)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def condition_dem(dem_path, config):
|
|
257
|
+
"""
|
|
258
|
+
Condition a DEM using Whitebox to remove pits, depressions
|
|
259
|
+
and flat areas
|
|
260
|
+
"""
|
|
261
|
+
# If we have been passed a dict of parameters, use these
|
|
262
|
+
if isinstance(config, dict):
|
|
263
|
+
# If a path to save the conditioned DEM has been provided,
|
|
264
|
+
# use this
|
|
265
|
+
if 'save_dem_to_path' in config:
|
|
266
|
+
# Get the path and remove it from the dict so that we
|
|
267
|
+
# don't pass it to the WhiteboxTools function
|
|
268
|
+
dem_path_conditioned = config.pop('save_dem_to_path')
|
|
269
|
+
dem_path_conditioned = Path(dem_path_conditioned).resolve()
|
|
270
|
+
temp_dir = None
|
|
271
|
+
else:
|
|
272
|
+
# If no path has been provided, create a temporary file
|
|
273
|
+
temp_dir = tempfile.TemporaryDirectory()
|
|
274
|
+
dem_path_conditioned = os.path.join(temp_dir.name,
|
|
275
|
+
'dem.tif')
|
|
276
|
+
# Use any remainning parameters in the dict as kwargs
|
|
277
|
+
kwargs = config
|
|
278
|
+
# Set dist=10000 if no value given
|
|
279
|
+
kwargs['dist'] = config.get('dist', 10000)
|
|
280
|
+
# Don't use any callback specified as we need to use our
|
|
281
|
+
# own to catch errors
|
|
282
|
+
if 'callback' in kwargs:
|
|
283
|
+
del kwargs['callback']
|
|
284
|
+
|
|
285
|
+
wbt = WhiteboxTools()
|
|
286
|
+
# Run the conditioning function and save the resulting DEM
|
|
287
|
+
# to the specified path
|
|
288
|
+
wbt.breach_depressions_least_cost(dem=dem_path,
|
|
289
|
+
output=dem_path_conditioned,
|
|
290
|
+
callback=whitebox_callback,
|
|
291
|
+
**kwargs)
|
|
292
|
+
return dem_path_conditioned, temp_dir
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import numpy as np
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class Router:
|
|
5
|
-
"""The Router class is responsible for using the flow direction raster to route water bodies within the
|
|
6
|
-
model domain, setting variables required by the NanoFASE model, such as inflows, outflows and headwaters."""
|
|
7
|
-
|
|
8
|
-
def __init__(self, flow_dir):
|
|
9
|
-
"""Initiliase the routing class with the flow direction array."""
|
|
10
|
-
self.flow_dir = flow_dir
|
|
11
|
-
self.grid_mask = self.flow_dir.mask
|
|
12
|
-
|
|
13
|
-
def outflow_from_flow_dir(self, x, y):
|
|
14
|
-
"""Get the outflow cell reference given the current cell
|
|
15
|
-
reference and a flow direction."""
|
|
16
|
-
xy_out = {
|
|
17
|
-
1: [x+1, y],
|
|
18
|
-
2: [x+1, y+1],
|
|
19
|
-
4: [x, y+1],
|
|
20
|
-
8: [x-1, y+1],
|
|
21
|
-
16: [x-1, y],
|
|
22
|
-
32: [x-1, y-1],
|
|
23
|
-
64: [x, y-1],
|
|
24
|
-
128: [x+1, y-1]
|
|
25
|
-
}
|
|
26
|
-
return xy_out[self.flow_dir[y-1, x-1]]
|
|
27
|
-
|
|
28
|
-
def inflows_from_flow_dir(self, x, y):
|
|
29
|
-
"""Get the inflow cell references given the current cell
|
|
30
|
-
reference and a flow direction."""
|
|
31
|
-
j, i = y - 1, x - 1
|
|
32
|
-
# Flow direction that means the cell with indices inflows[j_n-j, i_n-i]
|
|
33
|
-
# flows into this cell
|
|
34
|
-
inflow_flow_dir = {
|
|
35
|
-
(-1, -1): 2,
|
|
36
|
-
(0, -1): 1,
|
|
37
|
-
(1, -1): 128,
|
|
38
|
-
(-1, 0): 4,
|
|
39
|
-
(0, 0): 0,
|
|
40
|
-
(1, 0): 64,
|
|
41
|
-
(-1, 1): 8,
|
|
42
|
-
(0, 1): 16,
|
|
43
|
-
(1, 1): 32
|
|
44
|
-
}
|
|
45
|
-
inflow_cells = []
|
|
46
|
-
# Loop through the neighbours and check if they're inflows to this cell
|
|
47
|
-
for j_n in range(j-1, j+2):
|
|
48
|
-
for i_n in range(i-1, i+2):
|
|
49
|
-
if self.in_model_domain(i_n, j_n):
|
|
50
|
-
if self.flow_dir[j_n, i_n] == inflow_flow_dir[(j_n-j, i_n-i)]:
|
|
51
|
-
inflow_cells.append([i_n+1, j_n+1])
|
|
52
|
-
# Create masked array from the inflow_cells list
|
|
53
|
-
inflow_cells_ma = np.ma.array(np.ma.empty((7,2), dtype=int), mask=True)
|
|
54
|
-
if len(inflow_cells) > 0: # Only fill if there are inflows
|
|
55
|
-
inflow_cells_ma[0:len(inflow_cells)] = inflow_cells
|
|
56
|
-
return inflow_cells_ma
|
|
57
|
-
|
|
58
|
-
def in_model_domain(self, i, j):
|
|
59
|
-
"""Check if index [j,i] is in model domain."""
|
|
60
|
-
i_in_domain = 0 <= i < self.grid_mask.shape[1]
|
|
61
|
-
j_in_domain = 0 <= j < self.grid_mask.shape[0]
|
|
62
|
-
not_masked = self.flow_dir[j, i] is not np.ma.masked if (i_in_domain and j_in_domain) else False
|
|
63
|
-
return i_in_domain and j_in_domain and not_masked
|
|
64
|
-
|
|
65
|
-
def n_waterbodies_from_inflows(self, x, y, outflow, inflows):
|
|
66
|
-
"""Calculate the number of waterbodies from the inflows to the cell."""
|
|
67
|
-
# j, i = y - 1, x - 1
|
|
68
|
-
j_out, i_out = outflow[1] - 1, outflow[0] - 1
|
|
69
|
-
n_inflows = inflows.count(axis=0)[0] # Count the unmasked elements to get n_inflows
|
|
70
|
-
# If there are no inflows but the outflow is to the model domain, it
|
|
71
|
-
# must be a headwater. Else, number of waterbodies is same as number of inflows
|
|
72
|
-
if n_inflows == 0 and self.in_model_domain(i_out, j_out):
|
|
73
|
-
n_waterbodies = 1
|
|
74
|
-
is_headwater = 1
|
|
75
|
-
else:
|
|
76
|
-
n_waterbodies = n_inflows
|
|
77
|
-
is_headwater = 0
|
|
78
|
-
return n_waterbodies, is_headwater
|
|
79
|
-
|
|
80
|
-
# def generate_waterbody_code(self, x, y, outflow, inflows, is_estuary, is_headwater):
|
|
81
|
-
# """Generates character code of the waterbodies of this cell, of the format
|
|
82
|
-
# <type><index>(<inflow_point>:<outflow_point>/<n_waterbodies_along_branch>)...
|
|
83
|
-
# where type = r,e,l,s (river, estuary, lake, sea), index is 1-indexed, inflow
|
|
84
|
-
# and outflow_side are integers representing the geometrical point of inflow
|
|
85
|
-
# (0 for centre, 1 for top-left, 2 for top centre, etc.) and n_waterbodies_along_branch
|
|
86
|
-
# is the number of waterbodies between inflow and outflow."""
|
|
87
|
-
# outflow_point = self.point_index(outflow[1]-x, outflow[0]-y)
|
|
88
|
-
# wb_type = 'e' if is_estuary > 0 else 'r'
|
|
89
|
-
# wb_char = ''
|
|
90
|
-
# for i, inflow in enumerate(inflows):
|
|
91
|
-
# if not inflow.mask.any(): # Only if this cell isn't masked
|
|
92
|
-
# inflow_point = self.point_index(inflow[1]-x, inflow[0]-y)
|
|
93
|
-
# wb_char = wb_char + '{0}{1}({2}:{3}/01)'.format(wb_type, i+1, inflow_point, outflow_point)
|
|
94
|
-
# # If this is a headwater, there must be a waterbody with inflow at centre of cell
|
|
95
|
-
# if is_headwater:
|
|
96
|
-
# wb_char = '{0}1(0:{1}/01)'.format(wb_type, outflow_point)
|
|
97
|
-
# return wb_char
|
|
98
|
-
|
|
99
|
-
# def point_index(self, di, dj):
|
|
100
|
-
# point_index = {
|
|
101
|
-
# (-1, -1): 1,
|
|
102
|
-
# (0, -1): 8,
|
|
103
|
-
# (1, -1): 7,
|
|
104
|
-
# (-1, 0): 2,
|
|
105
|
-
# (0, 0): 0,
|
|
106
|
-
# (1, 0): 6,
|
|
107
|
-
# (-1, 1): 3,
|
|
108
|
-
# (0, 1): 4,
|
|
109
|
-
# (1, 1): 5
|
|
110
|
-
# }
|
|
111
|
-
# return point_index[dj, di]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|