wolfhece 2.1.126__py3-none-any.whl → 2.1.128__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.
wolfhece/eikonal.py ADDED
@@ -0,0 +1,505 @@
1
+ import numpy as np
2
+ import heapq
3
+ import multiprocessing as mp
4
+ import scipy.ndimage
5
+ import logging
6
+ from numba import njit
7
+ from tqdm import tqdm
8
+
9
+ @njit
10
+ def _evaluate_distance_and_data_first_order_iso(i, j,
11
+ fixed:np.ndarray, where_compute:np.ndarray,
12
+ dx_mesh:float, dy_mesh:float,
13
+ times:np.ndarray,
14
+ base_data:np.ndarray, test_data:np.ndarray,
15
+ speed:float = 1.0) -> tuple[float, float]:
16
+ """
17
+ Evaluate the time and data using a first order isotropic method.
18
+
19
+ :param i: (int): The row index.
20
+ :param j: (int): The column index.
21
+ :param fixed: (2D numpy array): A boolean array where True indicates fixed points.
22
+ :param where_compute: (2D numpy array): A boolean array where True indicates cells to be included in computation.
23
+ :param dx_mesh: (float): The mesh size in x direction.
24
+ :param dy_mesh: (float): The mesh size in y direction.
25
+ :param times: (2D numpy array): The time function.
26
+ :param base_data: (2D numpy array): The base data that will propagate.
27
+ :param test_data: (2D numpy array): The test data that will be used to validate propagation.
28
+ :param speed: (float): The isotropic propagation speed.
29
+
30
+ :return: tuple[float, float]: The time and data.
31
+ """
32
+
33
+ # search for fixed neighbors
34
+ fixed_neighbors = [(i + di, j + dj) for di, dj in [(-1, 0), (1, 0), (0, -1), (0, 1)] if fixed[i + di, j + dj] and not where_compute[i + di, j + dj] and test_data[i, j] < base_data[i + di, j + dj]]
35
+
36
+ if len(fixed_neighbors) == 0:
37
+ return np.inf, test_data[i, j]
38
+
39
+ a_data = a_time = np.float64(len(fixed_neighbors))
40
+ b_time = np.sum(np.asarray([np.abs(times[cur]) for cur in fixed_neighbors]), dtype=np.float64)
41
+ c_time = np.sum(np.asarray([times[cur] ** 2 for cur in fixed_neighbors]), dtype= np.float64)
42
+
43
+ b_data = np.sum(np.asarray([base_data[cur] for cur in fixed_neighbors]))
44
+
45
+ # Résolution d'une équation du second degré pour trouver la distance
46
+ b_time = -2.0 * b_time
47
+ # l'hypothèse implicite dx = dy est faite ici
48
+ c_time = c_time - dx_mesh*dy_mesh * speed**2.0
49
+
50
+ if b_time != 0. and c_time!= 0.:
51
+ Realisant = abs(b_time*b_time-4*a_time*c_time)
52
+ new_time = (-b_time+np.sqrt(Realisant)) / (2.0*a_time)
53
+
54
+ elif c_time == 0.0 and b_time != 0.:
55
+ new_time = -b_time/a_time
56
+
57
+ elif b_time == 0.:
58
+ new_time = np.sqrt(-c_time/a_time)
59
+
60
+ data = b_data/a_data
61
+
62
+ if data < test_data[i,j]:
63
+ return np.inf, test_data[i,j]
64
+
65
+ return new_time, data
66
+
67
+ def __solve_eikonal_with_data(sources:list[list[int,int]],
68
+ where_compute:np.ndarray,
69
+ base_data:np.ndarray,
70
+ test_data:np.ndarray,
71
+ speed:np.ndarray,
72
+ dx_mesh:float, dy_mesh:float) -> np.ndarray:
73
+ """ Solve the Eikonal equation using the Fast Marching Method (FMM).
74
+
75
+ Jit version of the function. The next one is the non-jit version which uses this.
76
+ """
77
+
78
+ # store the fixed points
79
+ # - fix sources points
80
+ # - fix every first element of the heap after pop
81
+ fixed = np.zeros(speed.shape, dtype = np.bool8)
82
+
83
+ # fix the border
84
+ fixed[:, 0] = True
85
+ fixed[:, -1] = True
86
+ fixed[0, :] = True
87
+ fixed[-1, :] = True
88
+
89
+ time = np.ones(base_data.shape) * np.inf
90
+ time[:,0] = 0.
91
+ time[:,-1] = 0.
92
+ time[0,:] = 0.
93
+ time[-1,:] = 0.
94
+
95
+ heap = [(0., sources[0])]
96
+ for source in sources:
97
+ time[source[0], source[1]] = 0
98
+ fixed[source[0], source[1]] = True
99
+ heapq.heappush(heap, (0., source))
100
+
101
+ while heap:
102
+ t, (i, j) = heapq.heappop(heap)
103
+
104
+ fixed[i,j] = True
105
+ where_compute[i,j] = False # as we are sure that this point is fixed
106
+
107
+ # Add neighbors to the heap if not already added
108
+ for di, dj in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
109
+ new_i, new_j = i + di, j + dj
110
+
111
+ if fixed[new_i, new_j] or not where_compute[new_i, new_j]:
112
+ continue
113
+
114
+ new_t, new_data = _evaluate_distance_and_data_first_order_iso(new_i, new_j,
115
+ fixed,
116
+ where_compute,
117
+ dx_mesh, dy_mesh,
118
+ time,
119
+ base_data,
120
+ test_data,
121
+ speed[new_i, new_j])
122
+
123
+ if new_t < time[new_i, new_j]:
124
+ time[new_i, new_j] = new_t
125
+ base_data[new_i, new_j] = new_data
126
+ heapq.heappush(heap, (new_t, (new_i, new_j)))
127
+
128
+ return time
129
+
130
+ def _solve_eikonal_with_data(sources:list[tuple[int,int]],
131
+ where_compute:np.ndarray=None,
132
+ base_data:np.ndarray=None,
133
+ test_data:np.ndarray=None,
134
+ speed:np.ndarray = None,
135
+ dx_mesh:float = 1.0, dy_mesh:float = 1.0) -> np.ndarray:
136
+ """
137
+ Solve the Eikonal equation using the Fast Marching Method (FMM).
138
+
139
+ :param sources: (list of tuples): The coordinates of the source points.
140
+ :param where_compute: (2D numpy array, optional): A boolean array where True indicates cells to be included in computation.
141
+ :param base_data: (2D numpy array, optional): The base data that will propagate.
142
+ :param test_data: (2D numpy array, optional): The test data that will be used to validate propagation.
143
+ :param speed: (2D numpy array): The speed function.
144
+ :param dx_mesh: (float, optional): The mesh size in x direction.
145
+ :param dy_mesh: (float, optional): The mesh size in y direction.
146
+
147
+ :return: 2D numpy array The solution to the Eikonal equation.
148
+ """
149
+
150
+ rows, cols = where_compute.shape
151
+
152
+ assert base_data.shape == where_compute.shape
153
+ assert test_data.shape == where_compute.shape
154
+
155
+ if len(sources) == 0:
156
+ logging.error("No sources provided")
157
+ return np.zeros((rows, cols))
158
+
159
+ if speed is None:
160
+ speed = np.ones((rows, cols))
161
+
162
+ # Ensure sources are tuple
163
+ #
164
+ # FIXME : We force a global tupleof tuples because list of tuple can cause problems with Numba
165
+ # https://numba.readthedocs.io/en/stable/reference/deprecation.html#deprecation-of-reflection-for-list-and-set-types
166
+ sources = list(list(source) for source in sources)
167
+
168
+ return __solve_eikonal_with_data(sources, where_compute, base_data, test_data, speed, dx_mesh, dy_mesh)
169
+
170
+ def _extract_patch_slices_with_margin(shape, labels, margin= 2):
171
+ """ Extract the slices of the patches with a margin around the labels.
172
+
173
+ :param shape: (tuple): The shape of the array.
174
+ :param labels: (2D numpy array): The labels of the patches.
175
+ :param margin: (int, optional): The margin around the labels.
176
+ """
177
+
178
+ slices = scipy.ndimage.find_objects(labels)
179
+ patches_slices = []
180
+
181
+ for s in slices:
182
+ sl1 = slice(max(0, s[0].start - margin), min(shape[0], s[0].stop + margin))
183
+ sl2 = slice(max(0, s[1].start - margin), min(shape[1], s[1].stop + margin))
184
+ patches_slices.append((sl1, sl2))
185
+
186
+ return patches_slices
187
+
188
+ def _process_submatrix(args):
189
+ """ Function to process a submatrix in a multiprocess. """
190
+
191
+ id_label, speed, where_compute, base_data, test_data, labels, dx, dy, NoData = args
192
+
193
+ # Ignore the border
194
+ labels[:,0] = -1
195
+ labels[:,-1] = -1
196
+ labels[0,:] = -1
197
+ labels[-1,:] = -1
198
+ sources = np.argwhere(np.logical_and(labels == 0, base_data != NoData))
199
+
200
+ if len(sources) == 0:
201
+ return (None, None)
202
+ return (args, _solve_eikonal_with_data(sources, where_compute, base_data, test_data, speed, dx, dy))
203
+
204
+ def _solve_eikonal_with_value_on_submatrices(where_compute:np.ndarray,
205
+ base_data:np.ndarray,
206
+ test_data:np.ndarray = None,
207
+ speed:np.ndarray = None,
208
+ multiprocess:bool = False,
209
+ dx:float = 1., dy:float = 1.,
210
+ ignore_last_patches:int = 1,
211
+ NoDataValue:float = 0.) -> np.ndarray:
212
+ """ Propagate data inside the mask using the Fast Marching Method (FMM).
213
+
214
+ "base_data" will be updated with the new values.
215
+
216
+ :param where_compute: (2D numpy array): A boolean array where True indicates cells to be included in computation.
217
+ :param base_data: (2D numpy array): The base data that will propagate.
218
+ :param test_data: (2D numpy array, optional): The test data that will be used to validate propagation (we used upstream data only if base_data > test_data).
219
+ :param speed: (2D numpy array, optional): The isotropic propagation speed.
220
+ :param multiprocess: (bool, optional): Whether to use multiprocessing.
221
+ :param dx: (float, optional): The mesh size in x direction.
222
+ :param dy: (float, optional): The mesh size in y direction.
223
+
224
+ :return: 2D numpy array The solution to the Eikonal equation.
225
+ """
226
+
227
+ # Labelling the patches
228
+ labels, numfeatures = scipy.ndimage.label(where_compute)
229
+
230
+ if ignore_last_patches > 0:
231
+ # counts the number of cells in each patch
232
+ sizes = np.bincount(labels.ravel())
233
+
234
+ # get the indices of the patches sorted by size
235
+ indices = np.argsort(sizes[1:])[::-1]
236
+
237
+ # ignore the last patches
238
+ for idx in indices[:ignore_last_patches]:
239
+ where_compute[labels == idx+1] = 0 # idx +1 because np.argsort starts at 1, so "indices" are shifted by 1
240
+
241
+ # relabel the patches
242
+ labels, numfeatures = scipy.ndimage.label(where_compute)
243
+
244
+ logging.info(f"Ignoring {ignore_last_patches} last patches.")
245
+
246
+ logging.info(f"Number of patches: {numfeatures}")
247
+
248
+ if numfeatures == 0:
249
+ logging.warning("No patch to compute.")
250
+ return np.zeros_like(where_compute)
251
+
252
+ if speed is None:
253
+ logging.debug("Speed not provided. Using default value of 1.")
254
+ speed = np.ones_like(where_compute)
255
+
256
+ if test_data is None:
257
+ logging.debug("Test data not provided. Using -inf.")
258
+ test_data = np.full_like(base_data, -np.inf)
259
+
260
+ # Extract the slices of the patches with a margin of 2 cells around the labels.
261
+ # 2 cells are added to avoid test during computation.
262
+ # The external border will be ignored.
263
+ patch_slices = _extract_patch_slices_with_margin(where_compute.shape, labels)
264
+
265
+ # Prepare the submatrices to be processed
266
+ subs = [(idx+1, speed[cur], where_compute[cur], base_data[cur], test_data[cur], labels[cur].copy(), dx, dy, NoDataValue) for idx, cur in enumerate(patch_slices)]
267
+
268
+ if multiprocess:
269
+ # In multiprocess mode, the base_data is updated in a local copy of the submatrix.
270
+ # We need to merge the results at the end.
271
+ with mp.Pool(processes=max(min(mp.cpu_count(), numfeatures),1)) as pool:
272
+ results = pool.map(_process_submatrix, subs)
273
+
274
+ time = np.zeros_like(where_compute)
275
+ for slice, (sub, result) in zip(patch_slices, results):
276
+ if result is None:
277
+ continue
278
+ useful_result = sub[3] != NoDataValue
279
+ base_data[slice][useful_result] = sub[3][useful_result]
280
+ time[slice][useful_result] = result[useful_result]
281
+
282
+ else:
283
+ # In single process mode, the base_data is updated in place.
284
+ # We do not need to merge the results but only the time.
285
+ # results = [_process_submatrix(sub) for sub in subs]
286
+
287
+ results = []
288
+ for sub in tqdm(subs):
289
+ results.append(_process_submatrix(sub))
290
+
291
+ time = np.zeros_like(where_compute)
292
+ for slice, (sub, result) in zip(patch_slices, results):
293
+ if result is None:
294
+ continue
295
+ useful_result = result != NoDataValue
296
+ time[slice][useful_result] = result[useful_result]
297
+
298
+ return time
299
+
300
+
301
+ def count_holes(mask:np.ndarray = None):
302
+ """ Count the number of holes in the mask. """
303
+
304
+ labels, numfeatures = scipy.ndimage.label(mask)
305
+
306
+ return numfeatures
307
+
308
+ def inpaint_array(data:np.ndarray | np.ma.MaskedArray,
309
+ where_compute:np.ndarray,
310
+ test:np.ndarray,
311
+ ignore_last_patches:int = 1,
312
+ inplace:bool = True,
313
+ dx:float = 1., dy:float = 1.,
314
+ NoDataValue:float = 0.,
315
+ multiprocess:bool = True) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
316
+ """ Inpaint the array using the Fast Marching Method (FMM).
317
+
318
+ Main idea:
319
+ - We have a 2D array "data" that we want to inpaint.
320
+ - We have a 2D array "test" that will be used to validate the inpainting.
321
+ - We have a 2D array "where_compute" that indicates where to compute the inpainting.
322
+ - We will use the FMM to propagate the data inside the mask.
323
+ - We will update the data only if the new value is greater than the test data.
324
+ - (We can ignore n greatest patches to avoid computation in some large areas.)
325
+
326
+ :param data: (2D numpy array): The water level to inpaint.
327
+ :param mask: (2D numpy array): The simulation's Digital Elevation Model (DEM).
328
+ :param test: (2D numpy array, optional): The digital terrain model (DTM).
329
+ :param ignore_last_patches: (int, optional): The number of last patches to ignore.
330
+ :param inplace: (bool, optional): Whether to update the water_level in place.
331
+ :param dx: (float, optional): The mesh size in x direction.
332
+ :param dy: (float, optional): The mesh size in y direction.
333
+ :param NoDataValue: (float, optional): The NoDataValue, used if mask is not explicitly provided (mask atribute or water_level as a Numpy MaskedArray). Default is 0.
334
+ :param multiprocess: (bool, optional): Whether to use multiprocessing.
335
+ """
336
+ if inplace:
337
+ if isinstance(data, np.ma.MaskedArray):
338
+ base_data = data.data
339
+ else:
340
+ base_data = data
341
+ else:
342
+ if isinstance(data, np.ma.MaskedArray):
343
+ base_data = data.data.copy()
344
+ else:
345
+ base_data = data.copy()
346
+
347
+ assert where_compute.shape == base_data.shape
348
+ assert test.shape == base_data.shape
349
+
350
+ time = _solve_eikonal_with_value_on_submatrices(where_compute,
351
+ base_data,
352
+ test,
353
+ speed=None,
354
+ dx=dx, dy=dy,
355
+ ignore_last_patches=ignore_last_patches,
356
+ NoDataValue=NoDataValue,
357
+ multiprocess=multiprocess)
358
+
359
+ if inplace:
360
+ if isinstance(data, np.ma.MaskedArray):
361
+ data.mask[:,:] = base_data == NoDataValue
362
+
363
+ extra = np.ma.masked_array(base_data - test, mask=data.mask.copy())
364
+ extra.data[extra.data <= 0.] = NoDataValue
365
+ extra.mask[extra.data == NoDataValue] = True
366
+ else:
367
+ extra = base_data - test
368
+ extra[extra <= 0.] = NoDataValue
369
+ else:
370
+ if isinstance(data, np.ma.MaskedArray):
371
+ data = np.ma.masked_array(base_data, mask=base_data == NoDataValue)
372
+
373
+ extra = np.ma.masked_array(base_data - test, mask=data.mask)
374
+ extra.data[extra.data <= 0.] = NoDataValue
375
+ extra.mask[extra.data == NoDataValue] = True
376
+ else:
377
+ extra = base_data - test
378
+ extra[extra <= 0.] = NoDataValue
379
+
380
+ return time, data, extra
381
+
382
+ def inpaint_waterlevel(water_level:np.ndarray | np.ma.MaskedArray,
383
+ dem:np.ndarray,
384
+ dtm:np.ndarray,
385
+ ignore_last_patches:int = 1,
386
+ inplace:bool = True,
387
+ dx:float = 1., dy:float = 1.,
388
+ NoDataValue:float = 0.,
389
+ multiprocess:bool = True,
390
+ epsilon:float = 1e-3) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
391
+ """ Inpaint the water level using the Fast Marching Method (FMM). Similar to the HOLES.EXE Fortran program.
392
+
393
+ Assumptions:
394
+ - The simulations are in a steady state.
395
+ - The flood extent is limited by:
396
+ - natural topography (where DEM == DTM)
397
+ - buildings or blocks of buildings
398
+ - protective structures
399
+
400
+ The goal is to propagate the free surface elevations into the buildings.
401
+
402
+ We calculate the difference between the DEM (including buildings and walls) and the DTM to identify where the buildings are.
403
+
404
+ Specifically:
405
+ - if it is natural topography, the differences will be zero or almost zero
406
+ - if it is a building or anthropogenic structure, the differences are significant
407
+
408
+ We set the elevations of the cells where the difference is zero to a value unreachable by the flood.
409
+ Thus, only buildings in contact with the flood will be affected and filled.
410
+
411
+ HOLES.EXE vs Python code:
412
+ - In "holes.exe", we must provide "in", "mask", and "dem" files:
413
+ - "in" is the water level
414
+ - "dem" is the digital terrain model (DTM) associated with the simulation's topo-bathymetry (not the topo-bathymetry itself)
415
+ - "mask" is the DTM where the buildings are identified and a large value (above the maximum water level) if the cell is not inside a building.
416
+ - The patches are identified by the water level "0.0" in the "in" file.
417
+ - FMM is used and new water levels are calculated and retained if greater than the "mask" value.
418
+ - The DTM is only used in the final part when evaluating the new water depths (Z-DTM).
419
+
420
+ - In Python, we must provide the water level, the DEM, and the DTM:
421
+ - The patches will be identified by the buildings array (filtered DEM - DTM).
422
+ - FMM is used and new water levels are calculated and retained if greater than the DTM value.
423
+ - FMM is only propagated in the cells where "buildings = True".
424
+
425
+ - Final results must be the same even if the algorithm is a little bit different.
426
+ - Fortran code demands the "mask" file to be provided (pre-computed/modified by the user), but in Python, we calculate it automatically from the DEM and DTM.
427
+ - We can use "inpaint_array" to be more flexible... by manually providing a "where_compute" and a "test" arrays
428
+
429
+ :param water_level: (2D numpy array): The water level to inpaint.
430
+ :param dem: (2D numpy array): The simulation's Digital Elevation Model (DEM).
431
+ :param dtm: (2D numpy array, optional): The digital terrain model (DTM).
432
+ :param ignore_last_patches: (int, optional): The number of last patches to ignore.
433
+ :param inplace: (bool, optional): Whether to update the water_level in place.
434
+ :param dx: (float, optional): The mesh size in x direction.
435
+ :param dy: (float, optional): The mesh size in y direction.
436
+ :param NoDataValue: (float, optional): The NoDataValue, used if mask is not explicitly provided (mask attribute or water_level as a Numpy MaskedArray). Default is 0.
437
+ :param multiprocess: (bool, optional): Whether to use multiprocessing.
438
+ """
439
+ if inplace:
440
+ if isinstance(water_level, np.ma.MaskedArray):
441
+ base_data = water_level.data
442
+ else:
443
+ base_data = water_level
444
+ else:
445
+ if isinstance(water_level, np.ma.MaskedArray):
446
+ base_data = water_level.data.copy()
447
+ else:
448
+ base_data = water_level.copy()
449
+
450
+ assert dem.shape == base_data.shape
451
+ assert dtm.shape == base_data.shape
452
+
453
+ # Create the mask where we can fill the water level
454
+
455
+ # first we identify the buildings by the difference between the DEM and the DTM
456
+ buildings = dem - dtm
457
+ # If DTM is above DEM, we set the value to 0
458
+ if np.any(buildings < 0.) > 0:
459
+ logging.warning("Some cells in the DTM are above the DEM.")
460
+ logging.info("Setting these values to 0.")
461
+ buildings[buildings < 0.] = 0.
462
+
463
+ # If DTM is below DEM, we set the value to 1
464
+ buildings[buildings <= epsilon] = 0.
465
+ buildings[buildings > 0.] = 1.
466
+
467
+ # We interpolate only if building cells are not already in the water_level
468
+ comp = np.logical_and(buildings == 1., base_data != NoDataValue)
469
+ if np.any(comp):
470
+ logging.warning("Some building cells are already flooded.")
471
+ logging.info("Ignoring these cells in the interpolation.")
472
+
473
+ buildings = np.logical_and(buildings, base_data == NoDataValue)
474
+
475
+ time = _solve_eikonal_with_value_on_submatrices(buildings,
476
+ base_data,
477
+ dtm,
478
+ speed=None,
479
+ dx=dx, dy=dy,
480
+ ignore_last_patches=ignore_last_patches,
481
+ NoDataValue=NoDataValue,
482
+ multiprocess=multiprocess)
483
+
484
+ if inplace:
485
+ if isinstance(water_level, np.ma.MaskedArray):
486
+ water_level.mask[:,:] = base_data == NoDataValue
487
+
488
+ water_height = np.ma.masked_array(base_data - dtm, mask=water_level.mask.copy())
489
+ water_height.data[water_height.data <= 0.] = NoDataValue
490
+ water_height.mask[water_height.data == NoDataValue] = True
491
+ else:
492
+ water_height = base_data - dtm
493
+ water_height[water_height <= 0.] = NoDataValue
494
+ else:
495
+ if isinstance(water_level, np.ma.MaskedArray):
496
+ water_level = np.ma.masked_array(base_data, mask=base_data == NoDataValue)
497
+
498
+ water_height = np.ma.masked_array(base_data - dtm, mask=water_level.mask)
499
+ water_height.data[water_height.data <= 0.] = NoDataValue
500
+ water_height.mask[water_height.data == NoDataValue] = True
501
+ else:
502
+ water_height = base_data - dtm
503
+ water_height[water_height <= 0.] = NoDataValue
504
+
505
+ return time, water_level, water_height