plans 1.1.0.post1.dev0__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.
- plans/__init__.py +41 -0
- plans/analyst.py +3092 -0
- plans/config.py +103 -0
- plans/data/.gitkeep +2 -0
- plans/data/fields.csv +61 -0
- plans/data/files.csv +26 -0
- plans/data/tools.csv +2 -0
- plans/datasets/__init__.py +42 -0
- plans/datasets/chrono.py +540 -0
- plans/datasets/core.py +6224 -0
- plans/datasets/spatial.py +1298 -0
- plans/geo.py +1397 -0
- plans/hydrology/__init__.py +42 -0
- plans/hydrology/core.py +723 -0
- plans/hydrology/downscaled.py +1024 -0
- plans/hydrology/upscaled.py +3890 -0
- plans/parsers/__init__.py +40 -0
- plans/parsers/flare.py +484 -0
- plans/parsers/inmet.py +587 -0
- plans/parsers/snirh.py +219 -0
- plans/project.py +964 -0
- plans/qutils.py +3648 -0
- plans/root.py +2256 -0
- plans/tools/__init__.py +0 -0
- plans/tools/analysis_climate_series_lulc.py +231 -0
- plans/tools/analysis_dto.py +193 -0
- plans/tools/analysis_lulc_parameters.py +269 -0
- plans/tools/analysis_lulc_series.py +220 -0
- plans/tools/analysis_soils_parameters.py +253 -0
- plans/tools/core.py +559 -0
- plans/tools/demo.py +168 -0
- plans/viewer.py +414 -0
- plans-1.1.0.post1.dev0.dist-info/METADATA +120 -0
- plans-1.1.0.post1.dev0.dist-info/RECORD +37 -0
- plans-1.1.0.post1.dev0.dist-info/WHEEL +5 -0
- plans-1.1.0.post1.dev0.dist-info/licenses/LICENSE +674 -0
- plans-1.1.0.post1.dev0.dist-info/top_level.txt +1 -0
plans/geo.py
ADDED
|
@@ -0,0 +1,1397 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
#
|
|
3
|
+
# Copyright (C) 2025 The Project Authors
|
|
4
|
+
# See pyproject.toml for authors/maintainers.
|
|
5
|
+
# See LICENSE for license details.
|
|
6
|
+
"""
|
|
7
|
+
Standalone geoprocessing routines with minimal dependencies.
|
|
8
|
+
|
|
9
|
+
Overview
|
|
10
|
+
--------
|
|
11
|
+
|
|
12
|
+
# todo [docstring] -- overview
|
|
13
|
+
Mauris gravida ex quam, in porttitor lacus lobortis vitae.
|
|
14
|
+
In a lacinia nisl. Pellentesque habitant morbi tristique senectus
|
|
15
|
+
et netus et malesuada fames ac turpis egestas.
|
|
16
|
+
|
|
17
|
+
Example
|
|
18
|
+
-------
|
|
19
|
+
|
|
20
|
+
# todo [docstring] -- examples
|
|
21
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
|
22
|
+
Nulla mollis tincidunt erat eget iaculis. Mauris gravida ex quam,
|
|
23
|
+
in porttitor lacus lobortis vitae. In a lacinia nisl.
|
|
24
|
+
|
|
25
|
+
.. code-block:: python
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
print("Hello World!")
|
|
29
|
+
|
|
30
|
+
Mauris gravida ex quam, in porttitor lacus lobortis vitae.
|
|
31
|
+
In a lacinia nisl. Mauris gravida ex quam, in porttitor lacus lobortis vitae.
|
|
32
|
+
In a lacinia nisl.
|
|
33
|
+
"""
|
|
34
|
+
# IMPORTS
|
|
35
|
+
# ***********************************************************************
|
|
36
|
+
# import modules from other libs
|
|
37
|
+
|
|
38
|
+
# Native imports
|
|
39
|
+
# =======================================================================
|
|
40
|
+
import datetime
|
|
41
|
+
|
|
42
|
+
# ... {develop}
|
|
43
|
+
|
|
44
|
+
# External imports
|
|
45
|
+
# =======================================================================
|
|
46
|
+
import matplotlib.pyplot as plt
|
|
47
|
+
import numpy as np
|
|
48
|
+
import pandas as pd
|
|
49
|
+
import geopandas as gpd
|
|
50
|
+
|
|
51
|
+
# ... {develop}
|
|
52
|
+
|
|
53
|
+
# Project-level imports
|
|
54
|
+
# =======================================================================
|
|
55
|
+
# import {module}
|
|
56
|
+
# ... {develop}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# CONSTANTS
|
|
60
|
+
# ***********************************************************************
|
|
61
|
+
# define constants in uppercase
|
|
62
|
+
|
|
63
|
+
# CONSTANTS -- Project-level
|
|
64
|
+
# =======================================================================
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ... {develop}
|
|
68
|
+
|
|
69
|
+
# CONSTANTS -- Module-level
|
|
70
|
+
# =======================================================================
|
|
71
|
+
# ... {develop}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# FUNCTIONS
|
|
75
|
+
# ***********************************************************************
|
|
76
|
+
|
|
77
|
+
# FUNCTIONS -- Project-level
|
|
78
|
+
# =======================================================================
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Geometry functions
|
|
82
|
+
# =======================================================================
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def extents_to_wkt_box(xmin, ymin, xmax, ymax):
|
|
86
|
+
"""
|
|
87
|
+
Returns a WKT box from the given extents (xmin, ymin, xmax, ymax).
|
|
88
|
+
|
|
89
|
+
:param xmin: Minimum x-coordinate (left)
|
|
90
|
+
:type xmin: float
|
|
91
|
+
:param ymin: Minimum y-coordinate (bottom)
|
|
92
|
+
:type xmin: float
|
|
93
|
+
:param xmax: Maximum x-coordinate (right)
|
|
94
|
+
:type xmax: float
|
|
95
|
+
:param ymax: Maximum y-coordinate (top)
|
|
96
|
+
:type ymax: float
|
|
97
|
+
:return: A string representing the box in WKT format
|
|
98
|
+
:rtype: str
|
|
99
|
+
"""
|
|
100
|
+
return f"POLYGON(({xmin} {ymin}, {xmin} {ymax}, {xmax} {ymax}, {xmax} {ymin}, {xmin} {ymin}))"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ... {develop}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Vector functions
|
|
107
|
+
# =======================================================================
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_basins_by_gauges(
|
|
111
|
+
basins,
|
|
112
|
+
gauges,
|
|
113
|
+
field_gauge,
|
|
114
|
+
field_basin,
|
|
115
|
+
field_basin_down,
|
|
116
|
+
field_geometry="geometry",
|
|
117
|
+
dissolve=True,
|
|
118
|
+
):
|
|
119
|
+
"""
|
|
120
|
+
Retrieve a list of upstream basins for a list of gauges.
|
|
121
|
+
|
|
122
|
+
:param basins: A GeoDataFrame containing the basin features.
|
|
123
|
+
:type basins: :class:`geopandas.GeoDataFrame`
|
|
124
|
+
:param gauges: A GeoDataFrame containing the gauge locations.
|
|
125
|
+
:type gauges: :class:`geopandas.GeoDataFrame`
|
|
126
|
+
:param field_gauge: The name of the column in `gauges` that contains the unique identifier for each gauge.
|
|
127
|
+
:type field_gauge: str
|
|
128
|
+
:param field_basin: The name of the column in `basins` that contains the unique identifier for each basin.
|
|
129
|
+
:type field_basin: str
|
|
130
|
+
:param field_basin_down: The name of the column in `basins` that contains the unique identifier of the downstream basin.
|
|
131
|
+
:type field_basin_down: str
|
|
132
|
+
:param field_geometry: The name of the geometry column. Default value = "geometry"
|
|
133
|
+
:type field_geometry: str
|
|
134
|
+
:param dissolve: If True, dissolves the upstream basins into a single feature for each gauge. Default value = True
|
|
135
|
+
:type dissolve: bool
|
|
136
|
+
:return: A GeoDataFrame with the upstream basins for each gauge, including the gauge identifier. Returns None if no gauges are found within any basin.
|
|
137
|
+
:rtype: :class:`geopandas.GeoDataFrame`
|
|
138
|
+
|
|
139
|
+
**Notes**
|
|
140
|
+
|
|
141
|
+
This function identifies the basins in which each gauge
|
|
142
|
+
is located and then finds all upstream basins for each of these basins.
|
|
143
|
+
The resulting GeoDataFrame can optionally be dissolved into a single feature for each gauge.
|
|
144
|
+
|
|
145
|
+
"""
|
|
146
|
+
# ensure gauges are in the same crs
|
|
147
|
+
if basins.crs != gauges.crs:
|
|
148
|
+
# reproject to basin crs
|
|
149
|
+
gauges = gauges.to_crs(basins.crs)
|
|
150
|
+
|
|
151
|
+
# first get codes from basins in gauges
|
|
152
|
+
gauges = gpd.sjoin(gauges, basins, how="inner", predicate="within").reset_index(
|
|
153
|
+
drop=True
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# check for actual join
|
|
157
|
+
if len(gauges) > 0:
|
|
158
|
+
# cleanup
|
|
159
|
+
ls_fields = [field_gauge, field_basin, field_geometry]
|
|
160
|
+
gauges = gauges[ls_fields].copy()
|
|
161
|
+
print("\n")
|
|
162
|
+
print(gauges)
|
|
163
|
+
ls_basins = []
|
|
164
|
+
for i in range(len(gauges)):
|
|
165
|
+
gauge_id = gauges[field_gauge].values[i]
|
|
166
|
+
basins_upstream = get_upstream_features(
|
|
167
|
+
features=basins,
|
|
168
|
+
field_id=field_basin,
|
|
169
|
+
field_id_down=field_basin_down,
|
|
170
|
+
start_id=gauges[field_basin].values[i],
|
|
171
|
+
include_start=True,
|
|
172
|
+
field_geometry="geometry",
|
|
173
|
+
)
|
|
174
|
+
if basins_upstream is not None:
|
|
175
|
+
# dissolve into a single feature
|
|
176
|
+
if dissolve:
|
|
177
|
+
basins_upstream = basins_upstream.dissolve()
|
|
178
|
+
# append the gauge id field
|
|
179
|
+
basins_upstream[field_gauge] = gauge_id
|
|
180
|
+
# append to list
|
|
181
|
+
ls_basins.append(basins_upstream[[field_gauge, field_geometry]].copy())
|
|
182
|
+
basins_up = pd.concat(ls_basins).reset_index(drop=True)
|
|
183
|
+
return basins_up
|
|
184
|
+
else:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def get_upstream_features(
|
|
189
|
+
features,
|
|
190
|
+
field_id,
|
|
191
|
+
field_id_down,
|
|
192
|
+
start_id,
|
|
193
|
+
include_start=True,
|
|
194
|
+
field_geometry="geometry",
|
|
195
|
+
is_starting=True,
|
|
196
|
+
):
|
|
197
|
+
"""
|
|
198
|
+
Retrieves all upstream features from a given starting point.
|
|
199
|
+
|
|
200
|
+
:param features: A DataFrame containing all features.
|
|
201
|
+
:type features: :class:`pandas.DataFrame` or :class:`geopandas.GeoDataFrame`
|
|
202
|
+
:param field_id: The name of the column containing the feature's unique ID.
|
|
203
|
+
:type field_id: str
|
|
204
|
+
:param field_id_down: The name of the column containing the ID of the downstream feature.
|
|
205
|
+
:type field_id_down: str
|
|
206
|
+
:param start_id: The ID of the starting feature.
|
|
207
|
+
:type start_id: int or str
|
|
208
|
+
:param include_start: [optional] Whether to include the starting feature in the output. Default value = True
|
|
209
|
+
:type include_start: bool
|
|
210
|
+
:param field_geometry: [optional] The name of the column containing the geometry of the feature. Default value = "geometry"
|
|
211
|
+
:type field_geometry: str
|
|
212
|
+
:param is_starting: [optional] A flag to indicate if it's the initial call of the function. Default value = True
|
|
213
|
+
:type is_starting: bool
|
|
214
|
+
:return: A DataFrame with the upstream features, or None if no upstream features are found.
|
|
215
|
+
:rtype: :class:`pandas.DataFrame` or :class:`geopandas.GeoDataFrame` or None
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
# assses first step condition
|
|
219
|
+
if is_starting:
|
|
220
|
+
# if is first step, clean up useless fields
|
|
221
|
+
ls_fields = [field_id, field_id_down]
|
|
222
|
+
# handle geometry field
|
|
223
|
+
if field_geometry is not None:
|
|
224
|
+
ls_fields.append(field_geometry)
|
|
225
|
+
features = features[ls_fields].copy()
|
|
226
|
+
if include_start:
|
|
227
|
+
# get the starting feature
|
|
228
|
+
feature_start = features[features[field_id].isin([start_id])].copy()
|
|
229
|
+
|
|
230
|
+
# filter the upstream features
|
|
231
|
+
features_up = features[features[field_id_down].isin([start_id])].copy()
|
|
232
|
+
|
|
233
|
+
# assess halt condition
|
|
234
|
+
if len(features_up) > 0:
|
|
235
|
+
# setup a list for concatenation
|
|
236
|
+
ls_ups = [features_up]
|
|
237
|
+
# enter upstream loop
|
|
238
|
+
for i in range(len(features_up)):
|
|
239
|
+
# assess the local new starting point
|
|
240
|
+
lcl_start_id = features_up[field_id].values[i]
|
|
241
|
+
# enter recursive mode
|
|
242
|
+
output = get_upstream_features(
|
|
243
|
+
features=features,
|
|
244
|
+
field_id=field_id,
|
|
245
|
+
field_id_down=field_id_down,
|
|
246
|
+
start_id=lcl_start_id,
|
|
247
|
+
is_starting=False,
|
|
248
|
+
)
|
|
249
|
+
# handle output
|
|
250
|
+
if output is not None:
|
|
251
|
+
ls_ups.append(output)
|
|
252
|
+
|
|
253
|
+
# handle to include start feature
|
|
254
|
+
if is_starting and include_start:
|
|
255
|
+
# include the starting feature
|
|
256
|
+
ls_ups.append(feature_start)
|
|
257
|
+
|
|
258
|
+
# concatenate all features
|
|
259
|
+
features_up = pd.concat(ls_ups).reset_index(drop=True)
|
|
260
|
+
return features_up
|
|
261
|
+
else:
|
|
262
|
+
# handle edge case for upstream feature
|
|
263
|
+
if is_starting and include_start:
|
|
264
|
+
return feature_start.reset_index(drop=True)
|
|
265
|
+
else:
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# Raster functions
|
|
270
|
+
# =======================================================================
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def get_array_bbox(array):
|
|
274
|
+
"""
|
|
275
|
+
Finds the bounding box for the content (1s) in a 2D pseudo-boolean array.
|
|
276
|
+
|
|
277
|
+
:param array: 2D numpy array with values 1 (content) and 0 (background)
|
|
278
|
+
:type array: :class:`numpy.ndarray`
|
|
279
|
+
:return: Dictionary with keys "i_min", "i_max", "j_min", "j_max" representing the row and column bounds of the content
|
|
280
|
+
:rtype: dict
|
|
281
|
+
"""
|
|
282
|
+
# Find rows and columns containing 1's
|
|
283
|
+
rows_with_content = np.where(array.sum(axis=1) > 0)[0]
|
|
284
|
+
cols_with_content = np.where(array.sum(axis=0) > 0)[0]
|
|
285
|
+
|
|
286
|
+
# If there's content, determine bounds; otherwise, return None for each bound
|
|
287
|
+
if rows_with_content.size > 0 and cols_with_content.size > 0:
|
|
288
|
+
i_min, i_max = rows_with_content[0], rows_with_content[-1]
|
|
289
|
+
j_min, j_max = cols_with_content[0], cols_with_content[-1]
|
|
290
|
+
else:
|
|
291
|
+
i_min = i_max = j_min = j_max = None
|
|
292
|
+
|
|
293
|
+
return {"i_min": i_min, "i_max": i_max, "j_min": j_min, "j_max": j_max}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# Basic processing of array values
|
|
297
|
+
# -----------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def convert(array, old_values, new_values):
|
|
301
|
+
"""
|
|
302
|
+
Convert values in array from old to new values.
|
|
303
|
+
|
|
304
|
+
:param array: Numpy array to convert values
|
|
305
|
+
:type array: :class:`numpy.ndarray`
|
|
306
|
+
:param old_values: iterable of old values
|
|
307
|
+
:type old_values: :class:`numpy.ndarray`
|
|
308
|
+
:param new_values: iterable of new values
|
|
309
|
+
:type new_values: :class:`numpy.ndarray`
|
|
310
|
+
:return: converted array
|
|
311
|
+
:rtype: :class:`numpy.ndarray`
|
|
312
|
+
"""
|
|
313
|
+
new = array * 0.0
|
|
314
|
+
for i in range(len(old_values)):
|
|
315
|
+
_old = old_values[i]
|
|
316
|
+
_new = new_values[i]
|
|
317
|
+
new = new + (_new * (array == _old))
|
|
318
|
+
return new
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def classify(array, upvalues, classes):
|
|
322
|
+
"""
|
|
323
|
+
Classify array based on list of upper values and list of classes values
|
|
324
|
+
|
|
325
|
+
:param array: numpy array to classify
|
|
326
|
+
:param upvalues: 1d numpy array of upper values
|
|
327
|
+
:param classes: 1d array of classes values
|
|
328
|
+
:return: numpy array reclassified
|
|
329
|
+
"""
|
|
330
|
+
new = array * 0.0
|
|
331
|
+
for i in range(len(upvalues)):
|
|
332
|
+
if i == 0:
|
|
333
|
+
new = new + ((array <= upvalues[i]) * classes[i])
|
|
334
|
+
else:
|
|
335
|
+
new = new + (
|
|
336
|
+
(array > upvalues[i - 1]) * (array <= upvalues[i]) * classes[i]
|
|
337
|
+
)
|
|
338
|
+
return new
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def normalize(array, min_value=0, max_value=100):
|
|
342
|
+
"""
|
|
343
|
+
Normalize array between min and max values
|
|
344
|
+
|
|
345
|
+
:param array: Input array or float
|
|
346
|
+
:type array: float or :class:`numpy.ndarray`
|
|
347
|
+
:param min_value: minimum vale
|
|
348
|
+
:type min_value: float
|
|
349
|
+
:param max_value: maximum value
|
|
350
|
+
:type max_value: float
|
|
351
|
+
:return: Output array or float
|
|
352
|
+
:rtype: :class:`numpy.ndarray`
|
|
353
|
+
"""
|
|
354
|
+
# Find the minimum and maximum values in the original array
|
|
355
|
+
old_min = np.min(array)
|
|
356
|
+
old_max = np.max(array)
|
|
357
|
+
|
|
358
|
+
# Calculate the range of the original array
|
|
359
|
+
old_range = old_max - old_min
|
|
360
|
+
# Calculate the range of the desired new scale
|
|
361
|
+
new_range = max_value - min_value
|
|
362
|
+
|
|
363
|
+
# Apply the normalization formula using NumPy's broadcasting capabilities
|
|
364
|
+
normalized = min_value + ((array - old_min) * new_range) / old_range
|
|
365
|
+
return normalized
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def fuzzify(array, min_value=None, max_value=None):
|
|
369
|
+
"""
|
|
370
|
+
Fuzzify array between min and max values with linear membership function
|
|
371
|
+
|
|
372
|
+
:param array: Input array or float
|
|
373
|
+
:type array: float or :class:`numpy.ndarray`
|
|
374
|
+
:param min_value: minimum value threshold
|
|
375
|
+
:type min_value: float
|
|
376
|
+
:param max_value: maximum value threshold
|
|
377
|
+
:type max_value: float
|
|
378
|
+
:return: Output array or float
|
|
379
|
+
:rtype: :class:`numpy.ndarray`
|
|
380
|
+
"""
|
|
381
|
+
# Find the minimum and maximum values in the original array
|
|
382
|
+
if min_value is None:
|
|
383
|
+
min_value = np.min(array)
|
|
384
|
+
if max_value is None:
|
|
385
|
+
max_value = np.max(array)
|
|
386
|
+
|
|
387
|
+
# Calculate the range of the original array
|
|
388
|
+
old_range = max_value - min_value
|
|
389
|
+
# Calculate the range of the desired new scale
|
|
390
|
+
new_range = 1
|
|
391
|
+
|
|
392
|
+
# Apply the normalization formula using NumPy's broadcasting capabilities
|
|
393
|
+
# X_normalized = min_value + ((X - old_min) * new_range) / old_range
|
|
394
|
+
fuzzified = 0.0 + ((array - min_value) * new_range) / old_range
|
|
395
|
+
return fuzzified
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def prune(array, min_value=None, max_value=None):
|
|
399
|
+
"""
|
|
400
|
+
Inclusive prune values in array
|
|
401
|
+
|
|
402
|
+
:param array: Numpy array to prune values
|
|
403
|
+
:type array: :class:`numpy.ndarray`
|
|
404
|
+
:param min_value: minimum values to prune (inclusive)
|
|
405
|
+
:type min_value: float
|
|
406
|
+
:param max_value: maximum values to prude (inclusive)
|
|
407
|
+
:type max_value: float
|
|
408
|
+
:return: pruned array
|
|
409
|
+
:rtype: :class:`numpy.ndarray`
|
|
410
|
+
"""
|
|
411
|
+
if min_value is not None:
|
|
412
|
+
array = np.where(array < min_value, min_value, array)
|
|
413
|
+
if max_value is not None:
|
|
414
|
+
array = np.where(array > max_value, max_value, array)
|
|
415
|
+
return array
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def upscale(array, weights=None, mode="mean"):
|
|
419
|
+
"""
|
|
420
|
+
Upscales an array using a specified mode.
|
|
421
|
+
|
|
422
|
+
:param array: The input array to be upscaled.
|
|
423
|
+
:type array: :class:`numpy.ndarray`
|
|
424
|
+
:param weights: [optional] An array of weights to apply. Default value = None
|
|
425
|
+
:type weights: :class:`numpy.ndarray`
|
|
426
|
+
:param mode: The mode to use for upscaling, either "mean" or "sum". Default value = "mean"
|
|
427
|
+
:type mode: str
|
|
428
|
+
:return: The upscaled value.
|
|
429
|
+
:rtype: int or float
|
|
430
|
+
"""
|
|
431
|
+
if weights is None:
|
|
432
|
+
weights = 1 # scalar 1 for broadcasting
|
|
433
|
+
weighted_sum = np.sum(array * weights)
|
|
434
|
+
if mode == "mean":
|
|
435
|
+
return weighted_sum / np.sum(weights)
|
|
436
|
+
elif mode == "sum":
|
|
437
|
+
return weighted_sum
|
|
438
|
+
else:
|
|
439
|
+
return weighted_sum
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def downscale_linear(scalar, covariate, mode="mean", scalar_region=None):
|
|
443
|
+
"""
|
|
444
|
+
Applies linear downscaling to a covariate array based on a scalar value.
|
|
445
|
+
|
|
446
|
+
:param scalar: The scalar value used for downscaling.
|
|
447
|
+
:type scalar: float
|
|
448
|
+
:param covariate: The covariate array to be downscaled.
|
|
449
|
+
:type covariate: :class:`numpy.ndarray`
|
|
450
|
+
:param scalar_region: Boolean array setting a valid region for the scalar value.
|
|
451
|
+
:type scalar_region: :class:`numpy.ndarray` or None
|
|
452
|
+
:param mode: The aggregation mode to use, "mean" or "sum". Default value = "mean"
|
|
453
|
+
:type mode: str
|
|
454
|
+
:return: The linearly downscaled array.
|
|
455
|
+
:rtype: :class:`numpy.ndarray`
|
|
456
|
+
"""
|
|
457
|
+
funcs = {"mean": np.nanmean, "sum": np.nansum}
|
|
458
|
+
# handle region
|
|
459
|
+
if scalar_region is None:
|
|
460
|
+
scalar_region = np.ones(shape=covariate.shape)
|
|
461
|
+
scalar_region_nan = np.where(scalar_region == 0, np.nan, scalar_region)
|
|
462
|
+
|
|
463
|
+
# compute upscaled
|
|
464
|
+
covariate_upscaled = funcs[mode](scalar_region_nan * covariate)
|
|
465
|
+
|
|
466
|
+
# apply linear downscaling formula
|
|
467
|
+
covariate_normalized = covariate / covariate_upscaled
|
|
468
|
+
return scalar * covariate_normalized
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def downscale_variance(
|
|
472
|
+
mean,
|
|
473
|
+
covariate,
|
|
474
|
+
scale_factor,
|
|
475
|
+
reverse=False,
|
|
476
|
+
mode="simple",
|
|
477
|
+
max_value=None,
|
|
478
|
+
min_value=None,
|
|
479
|
+
):
|
|
480
|
+
"""
|
|
481
|
+
Applies a downscaling operation to a mean array based on a covariance array and a scaling factor.
|
|
482
|
+
|
|
483
|
+
:param mean: The input mean array.
|
|
484
|
+
:type mean: :class:`numpy.ndarray`
|
|
485
|
+
:param covariate: The covariance array used for downscaling.
|
|
486
|
+
:type covariate: :class:`numpy.ndarray`
|
|
487
|
+
:param scale_factor: The factor by which to downscale.
|
|
488
|
+
:type scale_factor: float
|
|
489
|
+
:param reverse: Whether to mirror the downscaling effect. Default value = False
|
|
490
|
+
:type reverse: bool
|
|
491
|
+
:param mode: The post-processing mode to apply. Can be "simple", "prune", "truncate", or "compensate". Default value = "simple"
|
|
492
|
+
:type mode: str
|
|
493
|
+
:param max_value: [optional] The maximum value for pruning or truncation.
|
|
494
|
+
:type max_value: float
|
|
495
|
+
:param min_value: [optional] The minimum value for pruning or truncation.
|
|
496
|
+
:type min_value: float
|
|
497
|
+
:return: The downscaled array.
|
|
498
|
+
:rtype: :class:`numpy.ndarray`
|
|
499
|
+
"""
|
|
500
|
+
# get mask
|
|
501
|
+
downler = downscaling_mask(covariate=covariate, scale=scale_factor)
|
|
502
|
+
|
|
503
|
+
signal = 1
|
|
504
|
+
if reverse:
|
|
505
|
+
signal = -1
|
|
506
|
+
|
|
507
|
+
# compute simple scaling
|
|
508
|
+
grd = mean + (signal * downler)
|
|
509
|
+
|
|
510
|
+
# apply post-processing
|
|
511
|
+
if mode == "simple" or mode is None:
|
|
512
|
+
pass
|
|
513
|
+
elif mode == "prune" or mode == "truncate":
|
|
514
|
+
# this method does not ensure the same input mean
|
|
515
|
+
grd = prune(array=grd, max_value=max_value, min_value=min_value)
|
|
516
|
+
elif mode == "compensate":
|
|
517
|
+
# this method fix the truncation problem
|
|
518
|
+
grd_pruned = prune(array=grd, max_value=max_value, min_value=min_value)
|
|
519
|
+
grd = downscale_linear(scalar=mean, covariate=grd_pruned, mode="mean")
|
|
520
|
+
|
|
521
|
+
return grd
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# todo [refactor] -- evaluate to rename or move inside
|
|
525
|
+
def downscaling_mask(covariate, scale):
|
|
526
|
+
"""
|
|
527
|
+
Compute a downscaling mask by using a covariate array
|
|
528
|
+
|
|
529
|
+
:param covariate: Numpy array of a covariate
|
|
530
|
+
:type covariate: :class:`numpy.ndarray`
|
|
531
|
+
:param scale: scale factor (expected to be positive)
|
|
532
|
+
:type scale: float
|
|
533
|
+
:return: downscaling mask array
|
|
534
|
+
:rtype: :class:`numpy.ndarray`
|
|
535
|
+
"""
|
|
536
|
+
return scale * (covariate - np.mean(covariate))
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def buffer(grd_input, n_radius):
|
|
540
|
+
"""
|
|
541
|
+
Calculate a buffer mask
|
|
542
|
+
|
|
543
|
+
:param grd_input: Pseudo-boolean 2D numpy array where pixels with value 1 represent the foreground.
|
|
544
|
+
:type grd_input: :class:`numpy.ndarray`
|
|
545
|
+
:param n_radius: number of pixels for buffer (inclusive)
|
|
546
|
+
:type n_radius: int
|
|
547
|
+
:return: Pseudo-boolean 2D numpy array representing buffer area.
|
|
548
|
+
:rtype: :class:`numpy.ndarray`
|
|
549
|
+
"""
|
|
550
|
+
grd_distance = euclidean_distance(grd_input)
|
|
551
|
+
grd_buffer = np.where(grd_distance <= n_radius, 1, 0)
|
|
552
|
+
return grd_buffer
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def euclidean_distance(grd_input):
|
|
556
|
+
"""
|
|
557
|
+
Calculate the Euclidean distance from pixels with value 1
|
|
558
|
+
|
|
559
|
+
:param grd_input: Pseudo-boolean 2D numpy array where pixels with value 1 represent the foreground.
|
|
560
|
+
:type grd_input: :class:`numpy.ndarray`
|
|
561
|
+
:return: 2D numpy array representing the Euclidean distance.
|
|
562
|
+
:rtype: :class:`numpy.ndarray`
|
|
563
|
+
|
|
564
|
+
**Note**
|
|
565
|
+
|
|
566
|
+
- The function uses the `distance_transform_edt` from `scipy.ndimage` to compute the Euclidean distance.
|
|
567
|
+
- The inputs array is treated as a binary mask, and the distance is calculated from foreground pixels (value 1).
|
|
568
|
+
|
|
569
|
+
"""
|
|
570
|
+
from scipy.ndimage import distance_transform_edt
|
|
571
|
+
|
|
572
|
+
grd_input = 1 * (grd_input == 0) # reverse foreground values
|
|
573
|
+
# Calculate the distance map
|
|
574
|
+
return distance_transform_edt(grd_input)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# Special processing of array values
|
|
578
|
+
# -----------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
# MODELLING
|
|
581
|
+
# -----------------------------------------------------------------------
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def donwscale_parameter(upscaled_value, basin, covariate, mode="mean"):
|
|
585
|
+
output = downscale_linear(
|
|
586
|
+
scalar=upscaled_value, covariate=covariate, mode=mode, scalar_region=basin
|
|
587
|
+
)
|
|
588
|
+
return output
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def downscale_parameter_to_units(
|
|
592
|
+
upscaled_value, units, basin, covariate_table, mode="mean"
|
|
593
|
+
):
|
|
594
|
+
# convert values
|
|
595
|
+
covariate = convert(
|
|
596
|
+
array=units,
|
|
597
|
+
old_values=covariate_table["v"].values, # unit ids
|
|
598
|
+
new_values=covariate_table["w"].values, # downscaling weights
|
|
599
|
+
)
|
|
600
|
+
output = donwscale_parameter(
|
|
601
|
+
upscaled_value=upscaled_value, basin=basin, covariate=covariate, mode=mode
|
|
602
|
+
)
|
|
603
|
+
return output
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
# SOILS
|
|
607
|
+
# -----------------------------------------------------------------------
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def soils(slope, hand, slope_threshold=25, hand_threshold=5):
|
|
611
|
+
"""
|
|
612
|
+
Calculates a basic soil classification grid based on slope and HAND (Height Above Nearest Drainage) data.
|
|
613
|
+
|
|
614
|
+
:param slope: The slope array.
|
|
615
|
+
:type slope: :class:`numpy.ndarray`
|
|
616
|
+
:param hand: The HAND (Height Above Nearest Drainage) array.
|
|
617
|
+
:type hand: :class:`numpy.ndarray`
|
|
618
|
+
:param slope_threshold: The slope threshold for identifying neosols. Default value = 25
|
|
619
|
+
:type slope_threshold: int
|
|
620
|
+
:param hand_threshold: The HAND threshold for identifying alluvial soils. Default value = 5
|
|
621
|
+
:type hand_threshold: int
|
|
622
|
+
:return: A grid representing soil types (1: default, 2: neosols, 3: alluvial).
|
|
623
|
+
:rtype: :class:`numpy.ndarray`
|
|
624
|
+
"""
|
|
625
|
+
# todo [develop] -- this can have more features
|
|
626
|
+
grd_neosols = 1 * (slope >= slope_threshold)
|
|
627
|
+
grd_alluvial = 1 * (hand <= hand_threshold)
|
|
628
|
+
grd_alluvial = np.where(grd_neosols == 0, grd_alluvial, 0)
|
|
629
|
+
grd_soils = (slope * 0.0) + 1
|
|
630
|
+
grd_soils = grd_soils + (2 * grd_neosols) + (1 * grd_alluvial)
|
|
631
|
+
return grd_soils
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# DEM
|
|
635
|
+
# -----------------------------------------------------------------------
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def slope(dem, cellsize, degree=True):
|
|
639
|
+
"""
|
|
640
|
+
Calculate slope using gradient-based algorithms
|
|
641
|
+
|
|
642
|
+
:param dem: 2D numpy array representing the Digital Elevation Model (``DEM``).
|
|
643
|
+
:type dem: :class:`numpy.ndarray`
|
|
644
|
+
:param cellsize: The size of a grid cell in the ``DEM`` (both in x and y directions).
|
|
645
|
+
:type cellsize: float
|
|
646
|
+
:param degree: If True (default), the output slope values are in degrees. If False, output units are in radians.
|
|
647
|
+
:type degree: bool
|
|
648
|
+
:return: numpy array representing the slope values.
|
|
649
|
+
:rtype: :class:`numpy.ndarray`
|
|
650
|
+
"""
|
|
651
|
+
grad = np.gradient(dem)
|
|
652
|
+
gradx = grad[0] / cellsize
|
|
653
|
+
grady = grad[1] / cellsize
|
|
654
|
+
gradv = np.sqrt((gradx * gradx) + (grady * grady))
|
|
655
|
+
slope_array = np.arctan(gradv)
|
|
656
|
+
if degree:
|
|
657
|
+
slope_array = slope_array * 360 / (2 * np.pi)
|
|
658
|
+
return slope_array
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def twi(slope, flowacc, cellsize):
|
|
662
|
+
"""
|
|
663
|
+
Calculate the Topographic Wetness Index ``TWI``
|
|
664
|
+
|
|
665
|
+
:param slope: 2D numpy array representing the slope in degrees.
|
|
666
|
+
:type slope: :class:`numpy.ndarray`
|
|
667
|
+
:param flowacc: 2D numpy array representing the flow accumulation.
|
|
668
|
+
:type flowacc: :class:`numpy.ndarray`
|
|
669
|
+
:param cellsize: The size of a grid cell (delta x = delta y).
|
|
670
|
+
:type cellsize: float
|
|
671
|
+
:return: 2D numpy array representing the Topographic Wetness Index.
|
|
672
|
+
:rtype: :class:`numpy.ndarray`
|
|
673
|
+
|
|
674
|
+
**Note**
|
|
675
|
+
|
|
676
|
+
- The function uses the formula: TWI = ln( A / cellsize tan(S)), where A is flow accumulation, cellsize is the cell resolution and S is slope in radians.
|
|
677
|
+
- The inputs arrays `slope` and `flowacc` should have the same dimensions.
|
|
678
|
+
- The formula includes a small value (0.01) to prevent issues with tangent calculations for non-NaN values.
|
|
679
|
+
|
|
680
|
+
"""
|
|
681
|
+
# +0.01 is a hack for non-nan values
|
|
682
|
+
return np.log((flowacc / cellsize) / (np.tan((slope * np.pi / 180) + 0.01)))
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
# DRAINAGE AND ROUTING
|
|
686
|
+
# -----------------------------------------------------------------------
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def rivers_wedge(grd_rivers, wedge_width=3, wedge_depth=3):
|
|
690
|
+
"""
|
|
691
|
+
Generate a wedge-like trench along the river lines.
|
|
692
|
+
|
|
693
|
+
:param grd_rivers: Pseudo-boolean grid indicating the presence of rivers.
|
|
694
|
+
:type grd_rivers: :class:`numpy.ndarray`
|
|
695
|
+
:param wedge_width: Width (single-sided) in pixels. Default is 3.
|
|
696
|
+
:type wedge_width: int
|
|
697
|
+
:param wedge_depth: Depth in meters. Default is 3.
|
|
698
|
+
:type wedge_depth: float
|
|
699
|
+
:return: Grid of the wedge (positive).
|
|
700
|
+
:rtype: :class:`numpy.ndarray`
|
|
701
|
+
|
|
702
|
+
**Notes**
|
|
703
|
+
|
|
704
|
+
- The function generates a wedge-like trench along the river lines based on distance transform.
|
|
705
|
+
- The inputs array `grd_rivers` should be a pseudo-boolean grid where rivers are represented as 1 and others as 0.
|
|
706
|
+
- The width `w` controls the width of the trench, and `h` controls its height.
|
|
707
|
+
|
|
708
|
+
"""
|
|
709
|
+
grd_dist = euclidean_distance(grd_input=grd_rivers)
|
|
710
|
+
return (((-wedge_depth / wedge_width) * grd_dist) + wedge_depth) * (
|
|
711
|
+
grd_dist <= wedge_width
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def carve_dem(grd_dem, grd_rivers, wedge_width=3, wedge_depth=10):
|
|
716
|
+
"""
|
|
717
|
+
Burn a ``DEM`` map with river lines.
|
|
718
|
+
|
|
719
|
+
:param grd_dem: ``DEM`` map.
|
|
720
|
+
:type grd_dem: :class:`numpy.ndarray`
|
|
721
|
+
:param grd_rivers: River map (pseudo-boolean).
|
|
722
|
+
:type grd_rivers: :class:`numpy.ndarray`
|
|
723
|
+
:param wedge_width: Width parameter in cells. Default is 3.
|
|
724
|
+
:type wedge_width: int
|
|
725
|
+
:param wedge_depth: Depth parameter. Default is 10.
|
|
726
|
+
:type wedge_depth: float
|
|
727
|
+
:return: Burned ``DEM``.
|
|
728
|
+
:rtype: :class:`numpy.ndarray`
|
|
729
|
+
|
|
730
|
+
**Note**
|
|
731
|
+
|
|
732
|
+
- The function burns a ``DEM`` map with river lines, creating a wedge-like trench along the rivers.
|
|
733
|
+
- The inputs array `grd_rivers` should be a pseudo-boolean grid where rivers are represented as 1 and others as 0.
|
|
734
|
+
- The width `w` controls the width of the trench, and `h` controls its height.
|
|
735
|
+
|
|
736
|
+
"""
|
|
737
|
+
grd_wedge = rivers_wedge(
|
|
738
|
+
grd_rivers, wedge_width=wedge_width, wedge_depth=wedge_depth
|
|
739
|
+
)
|
|
740
|
+
return (grd_dem + wedge_depth) - grd_wedge
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def downstream_coordinates(n_dir, i, j, ldd_convention="wbt"):
|
|
744
|
+
"""
|
|
745
|
+
Compute i and j downstream cell coordinates based on cell flow direction.
|
|
746
|
+
|
|
747
|
+
:param n_dir: Flow direction code.
|
|
748
|
+
:type n_dir: int
|
|
749
|
+
:param i: i (row) array index.
|
|
750
|
+
:type i: int
|
|
751
|
+
:param j: j (column) array index.
|
|
752
|
+
:type j: int
|
|
753
|
+
:param ldd_convention: String of flow direction convention. Options: ``wbt``, ``d8``, ``pcraster``. Default is ``wbt``.
|
|
754
|
+
:type ldd_convention: str
|
|
755
|
+
:return: Dictionary of downstream i, j, and distance factor.
|
|
756
|
+
:rtype: dict
|
|
757
|
+
|
|
758
|
+
**Notes**
|
|
759
|
+
|
|
760
|
+
- Assumes a specific flow direction convention (``wbt``, ``d8``, ``pcraster``).
|
|
761
|
+
- The output dictionary contains keys 'i', 'j', and 'distance'.
|
|
762
|
+
- The 'i' and 'j' values represent downstream cell coordinates.
|
|
763
|
+
- The 'distance' value is the Euclidean distance to the downstream cell.
|
|
764
|
+
|
|
765
|
+
``LDD`` conventions:
|
|
766
|
+
|
|
767
|
+
``wbt`` convention derived from the WhiteboxTool.
|
|
768
|
+
|
|
769
|
+
.. csv-table:: ``wbt`` convention
|
|
770
|
+
:width: 30%
|
|
771
|
+
|
|
772
|
+
64, 128, 1
|
|
773
|
+
32, 0, 2
|
|
774
|
+
16, 8, 4
|
|
775
|
+
|
|
776
|
+
``d8`` convention derived from the SAGA tool.
|
|
777
|
+
|
|
778
|
+
.. csv-table:: ``d8`` convention
|
|
779
|
+
:width: 30%
|
|
780
|
+
|
|
781
|
+
6,7,8
|
|
782
|
+
5,0,1
|
|
783
|
+
4,3,2
|
|
784
|
+
|
|
785
|
+
``pcraster`` convention derived from the PC-Raster tool.
|
|
786
|
+
|
|
787
|
+
.. csv-table:: ``pcraster`` convention
|
|
788
|
+
:width: 30%
|
|
789
|
+
|
|
790
|
+
1,2,3
|
|
791
|
+
4,5,6
|
|
792
|
+
7,8,9
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
**Examples**
|
|
796
|
+
|
|
797
|
+
>>> downstream_coordinates(n_dir=1, i=3, j=4, ldd_convention='pcraster')
|
|
798
|
+
{'i': 4, 'j': 3, 'distance': np.float64(1.4142135623730951)}
|
|
799
|
+
|
|
800
|
+
Change convention:
|
|
801
|
+
|
|
802
|
+
>>> downstream_coordinates(n_dir=5, i=10, j=15, ldd_convention='d8')
|
|
803
|
+
{'i': 10, 'j': 14, 'distance': np.float64(1.0)}
|
|
804
|
+
|
|
805
|
+
"""
|
|
806
|
+
# directions dictionaries
|
|
807
|
+
dct_dirs = {
|
|
808
|
+
"wbt": {
|
|
809
|
+
"32": {"di": 0, "dj": -1},
|
|
810
|
+
"0": {"di": 0, "dj": 0},
|
|
811
|
+
"2": {"di": 0, "dj": 1},
|
|
812
|
+
"4": {"di": 1, "dj": 1},
|
|
813
|
+
"8": {"di": 1, "dj": 0},
|
|
814
|
+
"16": {"di": 1, "dj": -1},
|
|
815
|
+
"64": {"di": -1, "dj": -1},
|
|
816
|
+
"128": {"di": -1, "dj": 0},
|
|
817
|
+
"1": {"di": -1, "dj": 1},
|
|
818
|
+
},
|
|
819
|
+
"pcraster": {
|
|
820
|
+
"1": {"di": 1, "dj": -1},
|
|
821
|
+
"2": {"di": 1, "dj": 0},
|
|
822
|
+
"3": {"di": 1, "dj": 1},
|
|
823
|
+
"4": {"di": 0, "dj": -1},
|
|
824
|
+
"5": {"di": 0, "dj": 0},
|
|
825
|
+
"6": {"di": 0, "dj": 1},
|
|
826
|
+
"7": {"di": -1, "dj": -1},
|
|
827
|
+
"8": {"di": -1, "dj": 0},
|
|
828
|
+
"9": {"di": -1, "dj": 1},
|
|
829
|
+
},
|
|
830
|
+
"d8": {
|
|
831
|
+
"0": {"di": 0, "dj": 0},
|
|
832
|
+
"1": {"di": 0, "dj": 1},
|
|
833
|
+
"2": {"di": -1, "dj": 1},
|
|
834
|
+
"3": {"di": -1, "dj": 0},
|
|
835
|
+
"4": {"di": -1, "dj": -1},
|
|
836
|
+
"5": {"di": 0, "dj": -1},
|
|
837
|
+
"6": {"di": 1, "dj": -1},
|
|
838
|
+
"7": {"di": 1, "dj": 0},
|
|
839
|
+
"8": {"di": 1, "dj": 1},
|
|
840
|
+
},
|
|
841
|
+
}
|
|
842
|
+
# set dir dict
|
|
843
|
+
dct_dir = dct_dirs[ldd_convention.lower()]
|
|
844
|
+
di = dct_dir[str(n_dir)]["di"]
|
|
845
|
+
dj = dct_dir[str(n_dir)]["dj"]
|
|
846
|
+
dist = np.sqrt(np.power(di, 2) + np.power(dj, 2))
|
|
847
|
+
dct_output = {"i": i + di, "j": j + dj, "distance": dist}
|
|
848
|
+
return dct_output
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def distance_to_outlet(grd_ldd, n_res=30, ldd_convention="wbt"):
|
|
852
|
+
"""
|
|
853
|
+
Compute the distance to outlet ``DTO`` raster of a given basin.
|
|
854
|
+
|
|
855
|
+
:param grd_ldd: 2d numpy array of flow direction LDD
|
|
856
|
+
:type grd_ldd: :class:`numpy.ndarray`
|
|
857
|
+
:param n_res: Resolution factor for the output distance raster. Default is 30.
|
|
858
|
+
:type n_res: int
|
|
859
|
+
:param ldd_convention: String of flow direction convention. Options: ``wbt``, ``d8``, ``pcraster``. Default is ``wbt``.
|
|
860
|
+
:type ldd_convention: str
|
|
861
|
+
:return: 2d numpy array distance
|
|
862
|
+
:rtype: :class:`numpy.ndarray`
|
|
863
|
+
|
|
864
|
+
"""
|
|
865
|
+
|
|
866
|
+
def is_offgrid(i, j):
|
|
867
|
+
if i < 0:
|
|
868
|
+
return True
|
|
869
|
+
elif i >= n_rows:
|
|
870
|
+
return True
|
|
871
|
+
elif j < 0:
|
|
872
|
+
return True
|
|
873
|
+
elif j >= n_cols:
|
|
874
|
+
return True
|
|
875
|
+
else:
|
|
876
|
+
return False
|
|
877
|
+
|
|
878
|
+
def is_center(ldd):
|
|
879
|
+
# halt
|
|
880
|
+
dict_halt = {"pcraster": 5, "d8": 0, "wbt": 0}
|
|
881
|
+
if ldd == dict_halt[ldd_convention]:
|
|
882
|
+
return True
|
|
883
|
+
else:
|
|
884
|
+
return False
|
|
885
|
+
|
|
886
|
+
# compute total number of cells:
|
|
887
|
+
n_total = grd_ldd.size
|
|
888
|
+
# deploy out distance raster
|
|
889
|
+
grd_outdist = -1 * np.ones(shape=np.shape(grd_ldd), dtype="float32")
|
|
890
|
+
# set loop parameters
|
|
891
|
+
n_rows = np.shape(grd_ldd)[0]
|
|
892
|
+
n_cols = np.shape(grd_ldd)[1]
|
|
893
|
+
n_counter = 0
|
|
894
|
+
for i in range(n_rows):
|
|
895
|
+
for j in range(n_cols):
|
|
896
|
+
# print(f"\n{i}, {j}")
|
|
897
|
+
# initiate cell loop:
|
|
898
|
+
i_current = i
|
|
899
|
+
j_current = j
|
|
900
|
+
# first check: if outdist is already set
|
|
901
|
+
lcl_outdist = grd_outdist[i_current][j_current]
|
|
902
|
+
# not set:
|
|
903
|
+
if lcl_outdist == -1:
|
|
904
|
+
lcl_ldd = grd_ldd[i_current][j_current]
|
|
905
|
+
# set queues
|
|
906
|
+
list_accdist = list()
|
|
907
|
+
list_traced_is = list()
|
|
908
|
+
list_traced_js = list()
|
|
909
|
+
# trace loop
|
|
910
|
+
n_trace_count = 0
|
|
911
|
+
while True:
|
|
912
|
+
|
|
913
|
+
# get downstream position
|
|
914
|
+
dct_out = downstream_coordinates(
|
|
915
|
+
n_dir=lcl_ldd,
|
|
916
|
+
i=i_current,
|
|
917
|
+
j=j_current,
|
|
918
|
+
ldd_convention=ldd_convention,
|
|
919
|
+
)
|
|
920
|
+
i_next = dct_out["i"]
|
|
921
|
+
j_next = dct_out["j"]
|
|
922
|
+
n_dist = dct_out["distance"]
|
|
923
|
+
|
|
924
|
+
# print(f"ldd: {lcl_ldd}")
|
|
925
|
+
# print(f"next j: {j_next}")
|
|
926
|
+
# print(f"next i: {i_next}")
|
|
927
|
+
# append to queue
|
|
928
|
+
if n_trace_count == 0:
|
|
929
|
+
list_accdist.append(n_dist)
|
|
930
|
+
else:
|
|
931
|
+
list_accdist.append(n_dist + list_accdist[n_trace_count - 1])
|
|
932
|
+
list_traced_is.append(i_current)
|
|
933
|
+
list_traced_js.append(j_current)
|
|
934
|
+
# halting options
|
|
935
|
+
b_off = is_offgrid(i=i_next, j=j_next)
|
|
936
|
+
b_center = is_center(ldd=lcl_ldd)
|
|
937
|
+
if b_off:
|
|
938
|
+
# print("is off")
|
|
939
|
+
# QUIT
|
|
940
|
+
n_traced_outdist = 0
|
|
941
|
+
break
|
|
942
|
+
if b_center:
|
|
943
|
+
# print("is center")
|
|
944
|
+
# QUIT
|
|
945
|
+
n_traced_outdist = 0
|
|
946
|
+
break
|
|
947
|
+
# check if next cell is a mapped path
|
|
948
|
+
n_next_outdist = grd_outdist[i_next][j_next]
|
|
949
|
+
if n_next_outdist == -1:
|
|
950
|
+
# MOVE
|
|
951
|
+
i_current = i_next
|
|
952
|
+
j_current = j_next
|
|
953
|
+
# update flowdir
|
|
954
|
+
lcl_ldd = grd_ldd[i_current][j_current]
|
|
955
|
+
n_trace_count = n_trace_count + 1
|
|
956
|
+
else:
|
|
957
|
+
# QUIT
|
|
958
|
+
# print("is mapped")
|
|
959
|
+
n_traced_outdist = n_next_outdist
|
|
960
|
+
break
|
|
961
|
+
|
|
962
|
+
# now manipulate queues
|
|
963
|
+
vct_accdist = np.array(list_accdist)
|
|
964
|
+
vct_accdist_inv = vct_accdist[::-1]
|
|
965
|
+
vct_is = np.array(list_traced_is)
|
|
966
|
+
vct_js = np.array(list_traced_js)
|
|
967
|
+
# get outdist path
|
|
968
|
+
vct_outdist = vct_accdist_inv + n_traced_outdist
|
|
969
|
+
# burn loop
|
|
970
|
+
for k in range(len(vct_outdist)):
|
|
971
|
+
grd_outdist[vct_is[k]][vct_js[k]] = vct_outdist[k]
|
|
972
|
+
else:
|
|
973
|
+
pass
|
|
974
|
+
|
|
975
|
+
return n_res * grd_outdist
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
# SHALSTAB
|
|
979
|
+
# -----------------------------------------------------------------------
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def shalstab_wetness(
|
|
983
|
+
flowacc,
|
|
984
|
+
slope,
|
|
985
|
+
cellsize,
|
|
986
|
+
soil_phi,
|
|
987
|
+
soil_z,
|
|
988
|
+
soil_c,
|
|
989
|
+
soil_p,
|
|
990
|
+
water_p=997,
|
|
991
|
+
g=9.8,
|
|
992
|
+
degree=True,
|
|
993
|
+
kPa=True,
|
|
994
|
+
):
|
|
995
|
+
"""
|
|
996
|
+
Calculate the SHALSTAB wetness model
|
|
997
|
+
|
|
998
|
+
:param flowacc: flow accumulation map (square meters)
|
|
999
|
+
:type flowacc: :class:`numpy.ndarray`
|
|
1000
|
+
:param slope: slope map (degrees or radians)
|
|
1001
|
+
:type slope: :class:`numpy.ndarray`
|
|
1002
|
+
:param cellsize: grid cell size (meters)
|
|
1003
|
+
:type cellsize: float
|
|
1004
|
+
:param soil_phi: soil angle of internal friction (degrees or radians)
|
|
1005
|
+
:type soil_phi: :class:`numpy.ndarray` or float
|
|
1006
|
+
:param soil_z: soil depth (meters)
|
|
1007
|
+
:type soil_z: :class:`numpy.ndarray` or float
|
|
1008
|
+
:param soil_c: soil cohesion (Pa or N/m0² or kg/ms²)
|
|
1009
|
+
:type soil_c: :class:`numpy.ndarray` or float
|
|
1010
|
+
:param soil_p: soil density (kg/m0³)
|
|
1011
|
+
:type soil_p: :class:`numpy.ndarray` or float
|
|
1012
|
+
:param water_p: water density (kg/m0³)
|
|
1013
|
+
:type water_p: float
|
|
1014
|
+
:param g: gravity acceleration (m0 / s²)
|
|
1015
|
+
:type g: float
|
|
1016
|
+
:param degree: flag to note if slope and soil_phi are in degrees
|
|
1017
|
+
:type degree: bool
|
|
1018
|
+
:param kPa: flag to note if soil_c in kPa
|
|
1019
|
+
:type kPa: bool
|
|
1020
|
+
:return: map of q/T ratio
|
|
1021
|
+
:rtype: :class:`numpy.ndarray`
|
|
1022
|
+
"""
|
|
1023
|
+
|
|
1024
|
+
if degree:
|
|
1025
|
+
# convert to radians
|
|
1026
|
+
slope = np.radians(slope)
|
|
1027
|
+
soil_phi = np.radians(soil_phi)
|
|
1028
|
+
|
|
1029
|
+
if kPa:
|
|
1030
|
+
# convert to Pa
|
|
1031
|
+
soil_c = soil_c * 1000
|
|
1032
|
+
|
|
1033
|
+
# get density ratio
|
|
1034
|
+
density_r = soil_p / water_p
|
|
1035
|
+
|
|
1036
|
+
# get tangent ratio
|
|
1037
|
+
tan_r = 1 - (np.tan(slope) / np.tan(soil_phi))
|
|
1038
|
+
|
|
1039
|
+
slope_term = density_r * tan_r
|
|
1040
|
+
|
|
1041
|
+
# get force term
|
|
1042
|
+
cohesion_term = soil_c / (
|
|
1043
|
+
np.square(np.cos(slope)) * np.tan(soil_phi) * water_p * g * soil_z
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
topo_term = cellsize * np.sin(slope) / flowacc
|
|
1047
|
+
|
|
1048
|
+
# apply full equation
|
|
1049
|
+
q_t = topo_term * (slope_term + cohesion_term)
|
|
1050
|
+
|
|
1051
|
+
# force non-negative
|
|
1052
|
+
q_t = q_t * (q_t > 0) + 0.0001
|
|
1053
|
+
|
|
1054
|
+
shalstab_classes = classify(
|
|
1055
|
+
array=np.log10(q_t),
|
|
1056
|
+
upvalues=np.array([-3.1, -2.8, -2.5, -2.2, 10]),
|
|
1057
|
+
classes=np.array([6, 5, 4, 3, 2]),
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
# improve id 1 and id 7
|
|
1061
|
+
mask1 = 1 * (np.tan(slope) <= np.tan(soil_phi) * (1 - (1 / density_r)))
|
|
1062
|
+
mask7 = 1 * (np.tan(soil_phi) > np.tan(slope))
|
|
1063
|
+
|
|
1064
|
+
# shalstab_classes = (shalstab_classes * (mask1 != 1)) + mask1
|
|
1065
|
+
# shalstab_classes = (shalstab_classes * (mask7 != 1)) + (mask7 * 7)
|
|
1066
|
+
|
|
1067
|
+
return q_t, shalstab_classes
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
# USLE
|
|
1071
|
+
# -----------------------------------------------------------------------
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def usle_l(slope, cellsize):
|
|
1075
|
+
"""
|
|
1076
|
+
Calculate the USLE L factor of Wischmeier & Smith (1978)
|
|
1077
|
+
|
|
1078
|
+
# todo [docstring] improve notes sphinx design
|
|
1079
|
+
L = (x / 22.13) ^ m0
|
|
1080
|
+
|
|
1081
|
+
where:
|
|
1082
|
+
|
|
1083
|
+
m0 = 0.2 when sinθ < 0.01;
|
|
1084
|
+
m0 = 0.3 when 0.01 ≤ sinθ ≤ 0.03;
|
|
1085
|
+
m0 = 0.4 when 0.03 < sinθ < 0.05;
|
|
1086
|
+
m0 = 0.5 when sinθ ≥ 0.05
|
|
1087
|
+
|
|
1088
|
+
x is the plot lenght taken as 1.4142 * cellsize (diagonal length of cell)
|
|
1089
|
+
|
|
1090
|
+
:param slope: slope in degrees of terrain 2d array
|
|
1091
|
+
:type slope: :class:`numpy.ndarray`
|
|
1092
|
+
:param cellsize: cell size in meters
|
|
1093
|
+
:type cellsize: float
|
|
1094
|
+
:return: Wischmeier & Smith (1978) L factor 2d array
|
|
1095
|
+
:rtype: :class:`numpy.ndarray`
|
|
1096
|
+
|
|
1097
|
+
.. dropdown:: Notes
|
|
1098
|
+
:icon: bookmark-fill
|
|
1099
|
+
|
|
1100
|
+
This implementation follows the slope length (L) factor formulation
|
|
1101
|
+
proposed by Wischmeier & Smith (1978) as part of the Universal Soil Loss
|
|
1102
|
+
Equation (USLE). The L factor accounts for the effect of slope length on
|
|
1103
|
+
erosion by scaling soil loss relative to a standard plot length of
|
|
1104
|
+
22.13 m.
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
The slope length factor is computed as:
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
.. math::
|
|
1111
|
+
|
|
1112
|
+
L = {\\left( \\frac{x}{22.13} \\right)}^{m}
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
where:
|
|
1116
|
+
|
|
1117
|
+
* :math:`x` is the effective slope length (m), here approximated as
|
|
1118
|
+
:math:`x = 1.4142 \\times \\text{cellsize}`, corresponding to the
|
|
1119
|
+
diagonal length of a raster cell.
|
|
1120
|
+
* :math:`m` is an empirical exponent dependent on slope steepness,
|
|
1121
|
+
defined as a function of :math:`\\sin \\theta`.
|
|
1122
|
+
|
|
1123
|
+
The exponent :math:`m` is assigned according to the following criteria:
|
|
1124
|
+
|
|
1125
|
+
* :math:`m = 0.2` when :math:`\\sin \\theta < 0.01`
|
|
1126
|
+
* :math:`m = 0.3` when :math:`0.01 \\le \\sin \\theta \\le 0.03`
|
|
1127
|
+
* :math:`m = 0.4` when :math:`0.03 < \\sin \\theta < 0.05`
|
|
1128
|
+
* :math:`m = 0.5` when :math:`\\sin \\theta \\ge 0.05`
|
|
1129
|
+
|
|
1130
|
+
This formulation is widely used in raster-based implementations of USLE
|
|
1131
|
+
where slope length is approximated at the grid-cell scale.
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
.. dropdown:: References
|
|
1135
|
+
:icon: book
|
|
1136
|
+
|
|
1137
|
+
Wischmeier, W. H., & Smith, D. D. (1978). *Predicting rainfall erosion losses: A guide to conservation planning*.
|
|
1138
|
+
USDA Agriculture Handbook No. 537, U.S. Department of Agriculture, Washington, DC.
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
"""
|
|
1142
|
+
slope_rad = np.pi * 2 * slope / 360
|
|
1143
|
+
lcl_grad = np.sin(slope_rad)
|
|
1144
|
+
m = classify(
|
|
1145
|
+
lcl_grad,
|
|
1146
|
+
upvalues=(0.01, 0.03, 0.05, np.max(lcl_grad)),
|
|
1147
|
+
classes=(0.2, 0.3, 0.4, 0.5),
|
|
1148
|
+
)
|
|
1149
|
+
return np.power(np.sqrt(2) * cellsize / 22.13, m)
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def usle_s(slope):
|
|
1153
|
+
"""
|
|
1154
|
+
Calculates the USLE S-factor (slope steepness factor)
|
|
1155
|
+
based on the Wischmeier & Smith (1978) equation.
|
|
1156
|
+
|
|
1157
|
+
:param slope: Slope in degrees of terrain.
|
|
1158
|
+
:type slope: :class:`numpy.ndarray`
|
|
1159
|
+
:return: The USLE S-factor.
|
|
1160
|
+
:rtype: :class:`numpy.ndarray`
|
|
1161
|
+
|
|
1162
|
+
.. dropdown:: Notes
|
|
1163
|
+
:icon: bookmark-fill
|
|
1164
|
+
|
|
1165
|
+
This implementation follows the slope steepness (S) factor formulation
|
|
1166
|
+
proposed by Wischmeier & Smith (1978), where the slope angle is expressed
|
|
1167
|
+
in degrees and internally converted to radians prior to evaluation.
|
|
1168
|
+
|
|
1169
|
+
The factor is defined as a function of the sine of the slope angle
|
|
1170
|
+
(:math:`\\theta`), making it suitable for raster-based terrain analysis
|
|
1171
|
+
where slope is commonly derived in angular units.
|
|
1172
|
+
|
|
1173
|
+
.. math::
|
|
1174
|
+
|
|
1175
|
+
S = 65.41 \\, (\\sin \\theta)^2 + 4.56 \\, \\sin \\theta + 0.065
|
|
1176
|
+
|
|
1177
|
+
where:
|
|
1178
|
+
|
|
1179
|
+
* :math:`\\theta` is the slope angle in radians.
|
|
1180
|
+
* :math:`S` is a dimensionless slope steepness factor used in the
|
|
1181
|
+
Universal Soil Loss Equation (USLE).
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
.. dropdown:: References
|
|
1185
|
+
:icon: book
|
|
1186
|
+
|
|
1187
|
+
Wischmeier, W. H., & Smith, D. D. (1978). *Predicting rainfall erosion losses: A guide to conservation planning*.
|
|
1188
|
+
USDA Agriculture Handbook No. 537, U.S. Department of Agriculture, Washington, DC.
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
"""
|
|
1192
|
+
slope_rad = np.pi * 2 * slope / 360
|
|
1193
|
+
lcl_grad = np.sin(slope_rad)
|
|
1194
|
+
return (65.41 * np.power(lcl_grad, 2)) + (4.56 * lcl_grad) + 0.065
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def usle_m_a(q, prec, r, k, l, s, c, p, cellsize=30):
|
|
1198
|
+
"""
|
|
1199
|
+
USLE-M Annual Soil Loss (Kinnell & Risse, 1998)
|
|
1200
|
+
|
|
1201
|
+
# todo [docstring] improve notes sphinx design
|
|
1202
|
+
|
|
1203
|
+
:param q: Annual runoff in mm/year.
|
|
1204
|
+
:type q: :class:`numpy.ndarray`
|
|
1205
|
+
:param prec: Annual precipitation in mm/year.
|
|
1206
|
+
:type prec: :class:`numpy.ndarray` or float
|
|
1207
|
+
:param r: Rain erosivity in MJ mm h-1 ha-1 year-1.
|
|
1208
|
+
:type r: :class:`numpy.ndarray` or float
|
|
1209
|
+
:param k: Erodibility K factor in ton h MJ-1 mm-1.
|
|
1210
|
+
:type k: :class:`numpy.ndarray` or float
|
|
1211
|
+
:param l: USLE/RUSLE L factor.
|
|
1212
|
+
:type l: :class:`numpy.ndarray` or float
|
|
1213
|
+
:param s: USLE/RUSLE S factor.
|
|
1214
|
+
:type s: :class:`numpy.ndarray` or float
|
|
1215
|
+
:param c: C_UM factor.
|
|
1216
|
+
:type c: :class:`numpy.ndarray` or float
|
|
1217
|
+
:param p: USLE/RUSLE P factor.
|
|
1218
|
+
:type p: :class:`numpy.ndarray` or float
|
|
1219
|
+
:param cellsize: Grid cell size in meters. Default value = 30
|
|
1220
|
+
:type cellsize: float
|
|
1221
|
+
:return: Annual Soil Loss in ton/year.
|
|
1222
|
+
:rtype: :class:`numpy.ndarray`
|
|
1223
|
+
"""
|
|
1224
|
+
return (q / prec) * r * k * l * s * c * p * (cellsize * cellsize / (100 * 100))
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
# Sunlight simulation
|
|
1228
|
+
# -----------------------------------------------------------------------
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def julian_day(date):
|
|
1232
|
+
"""
|
|
1233
|
+
Compute Julian day (day of year).
|
|
1234
|
+
|
|
1235
|
+
:param date: Calendar date in `YYYY-MM-DD` format
|
|
1236
|
+
:type date: str
|
|
1237
|
+
:return: Julian day
|
|
1238
|
+
:rtype: int
|
|
1239
|
+
|
|
1240
|
+
"""
|
|
1241
|
+
year = int(date.split("-")[0])
|
|
1242
|
+
month = int(date.split("-")[1])
|
|
1243
|
+
day = int(date.split("-")[2])
|
|
1244
|
+
dt = datetime.date(year, month, day)
|
|
1245
|
+
return dt.timetuple().tm_yday
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def solar_declination(date):
|
|
1249
|
+
"""
|
|
1250
|
+
Solar declination based on Cooper (1969) widely used approximation
|
|
1251
|
+
|
|
1252
|
+
:param date: Calendar date in `YYYY-MM-DD` format
|
|
1253
|
+
:type date: str
|
|
1254
|
+
:return: Solar declination angle in degrees
|
|
1255
|
+
:rtype: float
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
"""
|
|
1260
|
+
j = julian_day(date=date)
|
|
1261
|
+
return np.deg2rad(23.45 * np.sin(np.deg2rad(360 * (284 + j) / 365)))
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def solar_hour(hour):
|
|
1265
|
+
"""
|
|
1266
|
+
Solar hour angle (0° at solar noon, ±15° per hour)
|
|
1267
|
+
|
|
1268
|
+
:param hour: Local solar time [0–24]
|
|
1269
|
+
:type hour: float
|
|
1270
|
+
:return: Hour angle in degrees
|
|
1271
|
+
:rtype: float
|
|
1272
|
+
"""
|
|
1273
|
+
return np.deg2rad(15 * (hour - 12))
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def solar_altitude(latitude_deg, hour, date):
|
|
1277
|
+
"""
|
|
1278
|
+
Compute solar vertical angle (sun elevation or altitude) for a given latitude,
|
|
1279
|
+
date, and local solar time.
|
|
1280
|
+
|
|
1281
|
+
:param latitude_deg: Latitude in degrees (positive north, negative south)
|
|
1282
|
+
:type latitude_deg: float
|
|
1283
|
+
:param hour: Local solar time [0–24]
|
|
1284
|
+
:type hour: float
|
|
1285
|
+
:param date: Calendar date in `YYYY-MM-DD` format
|
|
1286
|
+
:type date: str
|
|
1287
|
+
:return: Solar vertical angle in degrees above the horizon (aka solar altitude)
|
|
1288
|
+
:rtype: float
|
|
1289
|
+
"""
|
|
1290
|
+
|
|
1291
|
+
# Convert latitude to radians
|
|
1292
|
+
# ------------------------------------------------------------------
|
|
1293
|
+
lat = np.deg2rad(latitude_deg)
|
|
1294
|
+
|
|
1295
|
+
# Solar declination
|
|
1296
|
+
# ------------------------------------------------------------------
|
|
1297
|
+
delta = solar_declination(date=date)
|
|
1298
|
+
|
|
1299
|
+
# Hour angle (0° at solar noon, ±15° per hour)
|
|
1300
|
+
# ------------------------------------------------------------------
|
|
1301
|
+
h = solar_hour(hour)
|
|
1302
|
+
|
|
1303
|
+
# Solar altitude (vertical angle)
|
|
1304
|
+
# ------------------------------------------------------------------
|
|
1305
|
+
sin_alpha = np.sin(lat) * np.sin(delta) + np.cos(lat) * np.cos(delta) * np.cos(h)
|
|
1306
|
+
|
|
1307
|
+
alpha = np.arcsin(sin_alpha)
|
|
1308
|
+
alpha_deg = np.rad2deg(alpha)
|
|
1309
|
+
|
|
1310
|
+
if alpha_deg <= 0:
|
|
1311
|
+
# Sun is below the horizon for the given setting. Defaulting to 0.
|
|
1312
|
+
alpha_deg = 0
|
|
1313
|
+
|
|
1314
|
+
return alpha_deg
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def solar_azimuth(latitude_deg, hour, date):
|
|
1318
|
+
"""
|
|
1319
|
+
Compute solar azimuth angle (degrees clockwise from North).
|
|
1320
|
+
|
|
1321
|
+
:param latitude_deg: Latitude in degrees (positive north, negative south)
|
|
1322
|
+
:type latitude_deg: float
|
|
1323
|
+
:param hour: Local solar time [0–24]
|
|
1324
|
+
:type hour: float
|
|
1325
|
+
:param date: Calendar date in `YYYY-MM-DD` format
|
|
1326
|
+
:type date: str
|
|
1327
|
+
:return: Solar azimuth angle in degrees
|
|
1328
|
+
:rtype: float
|
|
1329
|
+
"""
|
|
1330
|
+
|
|
1331
|
+
# Convert latitude to radians
|
|
1332
|
+
# ------------------------------------------------------------------
|
|
1333
|
+
lat = np.deg2rad(latitude_deg)
|
|
1334
|
+
|
|
1335
|
+
# Solar declination (Cooper, 1969 – widely used approximation)
|
|
1336
|
+
# ------------------------------------------------------------------
|
|
1337
|
+
delta = solar_declination(date)
|
|
1338
|
+
|
|
1339
|
+
# Hour angle
|
|
1340
|
+
# ------------------------------------------------------------------
|
|
1341
|
+
h = solar_hour(hour)
|
|
1342
|
+
|
|
1343
|
+
# Azimuth calculation
|
|
1344
|
+
# ------------------------------------------------------------------
|
|
1345
|
+
a = np.arctan2(np.sin(h), np.cos(h) * np.sin(lat) - np.tan(delta) * np.cos(lat))
|
|
1346
|
+
|
|
1347
|
+
# Convert to degrees, shift reference to North, normalize
|
|
1348
|
+
a_deg = (np.rad2deg(a) + 180) % 360
|
|
1349
|
+
|
|
1350
|
+
return a_deg
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def solar_illumination(year, latitude_deg, frequency="1h"):
|
|
1354
|
+
"""
|
|
1355
|
+
Simulate hourly solar altitude and azimuth for a full year.
|
|
1356
|
+
|
|
1357
|
+
:param year: Year to simulate
|
|
1358
|
+
:type year: int
|
|
1359
|
+
:param latitude_deg: Latitude in degrees
|
|
1360
|
+
:type latitude_deg: float
|
|
1361
|
+
:param frequency: Frequency flag for step simulation based on pandas
|
|
1362
|
+
:type frequency: str
|
|
1363
|
+
:return: Simulation data
|
|
1364
|
+
:rtype: pandas.`DataFrame`
|
|
1365
|
+
"""
|
|
1366
|
+
|
|
1367
|
+
# Hourly time index for the full year
|
|
1368
|
+
dt_index = pd.date_range(
|
|
1369
|
+
start=f"{year}-01-01",
|
|
1370
|
+
end=f"{year + 1}-01-01",
|
|
1371
|
+
freq=frequency,
|
|
1372
|
+
inclusive="left",
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
records = []
|
|
1376
|
+
|
|
1377
|
+
for dt in dt_index:
|
|
1378
|
+
hour = dt.hour + dt.minute / 60.0
|
|
1379
|
+
j = julian_day(date=dt.strftime("%Y-%m-%d"))
|
|
1380
|
+
|
|
1381
|
+
alt = solar_altitude(latitude_deg, hour, dt.strftime("%Y-%m-%d"))
|
|
1382
|
+
az = solar_azimuth(latitude_deg, hour, dt.strftime("%Y-%m-%d"))
|
|
1383
|
+
|
|
1384
|
+
if alt == 0:
|
|
1385
|
+
alt = np.nan
|
|
1386
|
+
az = np.nan
|
|
1387
|
+
|
|
1388
|
+
records.append(
|
|
1389
|
+
{
|
|
1390
|
+
"datetime": dt,
|
|
1391
|
+
"julian_day": j,
|
|
1392
|
+
"altitude": alt,
|
|
1393
|
+
"azimuth": az,
|
|
1394
|
+
}
|
|
1395
|
+
)
|
|
1396
|
+
|
|
1397
|
+
return pd.DataFrame.from_records(records)
|