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/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)