xslope 0.1.2__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.
xslope/search.py ADDED
@@ -0,0 +1,416 @@
1
+ # Copyright 2025 Norman L. Jones
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import time
16
+
17
+ import numpy as np
18
+ from shapely.geometry import LineString, Point
19
+
20
+ from . import solve
21
+ from .advanced import rapid_drawdown
22
+ from .slice import generate_slices, get_y_from_intersection
23
+
24
+ def circular_search(slope_data, method_name, rapid=False, tol=1e-2, max_iter=50, shrink_factor=0.5,
25
+ fs_fail=9999, depth_tol_frac=0.03, diagnostic=False):
26
+ """
27
+ Global 9-point circular search with adaptive grid refinement.
28
+
29
+ Returns:
30
+ list of dict: sorted fs_cache by FS
31
+ bool: convergence flag
32
+ list of dict: search path
33
+ """
34
+
35
+ solver = getattr(solve, method_name)
36
+
37
+ start_time = time.time() # Start timing
38
+
39
+ ground_surface = slope_data['ground_surface']
40
+ ground_surface = LineString([(x, y) for x, y in ground_surface.coords])
41
+ y_max = max(y for _, y in ground_surface.coords)
42
+ y_min = slope_data['max_depth']
43
+ delta_y = y_max - y_min
44
+ tol = delta_y * depth_tol_frac
45
+
46
+ circles = slope_data['circles']
47
+ max_depth = slope_data['max_depth']
48
+
49
+ def optimize_depth(x, y, depth_guess, depth_step_init, depth_shrink_factor, tol_frac, fs_fail, diagnostic=False):
50
+ depth_step = min(10.0, depth_step_init)
51
+ best_depth = max(depth_guess, max_depth)
52
+ best_fs = fs_fail
53
+ best_result = None
54
+ depth_tol = depth_step * tol_frac
55
+ iterations = 0
56
+
57
+ while depth_step > depth_tol:
58
+ depths = [
59
+ max(best_depth - depth_step, max_depth),
60
+ best_depth,
61
+ best_depth + depth_step
62
+ ]
63
+ fs_results = []
64
+ for d in depths:
65
+ test_circle = {'Xo': x, 'Yo': y, 'Depth': d, 'R': y - d}
66
+ success, result = generate_slices(slope_data, circle=test_circle)
67
+ if not success:
68
+ FS = fs_fail
69
+ df_slices = None
70
+ failure_surface = None
71
+ solver_result = None
72
+ else:
73
+ df_slices, failure_surface = result
74
+ if rapid:
75
+ solver_success, solver_result = rapid_drawdown(df_slices, method_name)
76
+ else:
77
+ solver_success, solver_result = solver(df_slices)
78
+ FS = solver_result['FS'] if solver_success else fs_fail
79
+ fs_results.append((FS, d, df_slices, failure_surface, solver_result))
80
+
81
+ fs_results.sort(key=lambda t: t[0])
82
+ best_fs, best_depth, best_df, best_surface, best_solver_result = fs_results[0]
83
+
84
+ if all(FS == fs_fail for FS, *_ in fs_results):
85
+ if diagnostic:
86
+ print(f"[❌ all fail] x={x:.2f}, y={y:.2f}")
87
+ return best_depth, fs_fail, None, None, None
88
+
89
+ if diagnostic:
90
+ print(f"[✓ depth-opt] x={x:.2f}, y={y:.2f}, depth={best_depth:.2f}, FS={best_fs:.4f}, step={depth_step:.2f}")
91
+
92
+ depth_step *= depth_shrink_factor
93
+ iterations += 1
94
+ if iterations > 50:
95
+ if diagnostic:
96
+ print(f"[⚠️ warning] depth iterations exceeded at (x={x:.2f}, y={y:.2f})")
97
+ break
98
+
99
+ return best_depth, best_fs, best_df, best_surface, best_solver_result
100
+
101
+ def evaluate_grid(x0, y0, grid_size, depth_guess, slope_data, diagnostic=False, fs_cache=None):
102
+ if fs_cache is None:
103
+ fs_cache = {}
104
+
105
+ Xs = [x0 - grid_size, x0, x0 + grid_size]
106
+ Ys = [y0 - grid_size, y0, y0 + grid_size]
107
+ points = [(x, y) for y in Ys for x in Xs]
108
+
109
+ for i, (x, y) in enumerate(points):
110
+ if (x, y) in fs_cache:
111
+ result = fs_cache[(x, y)]
112
+ if diagnostic:
113
+ print(f"[cache hit] grid pt {i + 1}/9 at (x={x:.2f}, y={y:.2f}) → FS={result['FS']:.4f}")
114
+ continue
115
+
116
+ depth_step_init = grid_size * 0.75
117
+ d, FS, df_slices, failure_surface, solver_result = optimize_depth(
118
+ x, y, depth_guess, depth_step_init, depth_shrink_factor=0.25, tol_frac=0.01, fs_fail=fs_fail,
119
+ diagnostic=diagnostic
120
+ )
121
+
122
+ fs_cache[(x, y)] = {
123
+ "Xo": x,
124
+ "Yo": y,
125
+ "Depth": d,
126
+ "FS": FS,
127
+ "slices": df_slices,
128
+ "failure_surface": failure_surface,
129
+ "solver_result": solver_result
130
+ }
131
+
132
+ if diagnostic:
133
+ print(f"[grid pt {i + 1}/9] x={x:.2f}, y={y:.2f} → FS={FS:.4f} at d={d:.2f}")
134
+
135
+ sorted_fs = sorted(fs_cache.items(), key=lambda item: item[1]['FS'])
136
+ best_point = sorted_fs[0][1]
137
+ best_index = list(fs_cache.keys()).index((best_point['Xo'], best_point['Yo']))
138
+
139
+ if diagnostic:
140
+ print(f"[★ grid best {best_index + 1}/9] FS={best_point['FS']:.4f} at (x={best_point['Xo']:.2f}, y={best_point['Yo']:.2f})")
141
+
142
+ return fs_cache, best_point
143
+
144
+ # === Step 1: Evaluate starting circles ===
145
+ all_starts = []
146
+ for i, start_circle in enumerate(circles):
147
+ x0 = start_circle['Xo']
148
+ y0 = start_circle['Yo']
149
+ r0 = y0 - start_circle['Depth']
150
+ if diagnostic:
151
+ print(f"\n[⏱ starting circle {i+1}] x={x0:.2f}, y={y0:.2f}, r={r0:.2f}")
152
+ grid_size = r0 * 0.15
153
+ depth_guess = start_circle['Depth']
154
+ fs_cache, best_point = evaluate_grid(x0, y0, grid_size, depth_guess, slope_data, diagnostic=diagnostic)
155
+ all_starts.append((start_circle, best_point, fs_cache))
156
+
157
+ all_starts.sort(key=lambda t: t[1]['FS'])
158
+ start_circle, best_start, fs_cache = all_starts[0]
159
+ x0 = best_start['Xo']
160
+ y0 = best_start['Yo']
161
+ depth_guess = best_start['Depth']
162
+ grid_size = (y0 - depth_guess) * 0.15
163
+ best_fs = best_start['FS']
164
+
165
+ # Include initial jump from user-defined circle to best point on its grid
166
+ search_path = [
167
+ {"x": start_circle['Xo'], "y": start_circle['Yo'], "FS": None},
168
+ {"x": x0, "y": y0, "FS": best_fs}
169
+ ]
170
+ converged = False
171
+
172
+ if diagnostic:
173
+ print(f"\n[✅ launch grid] Starting refinement from FS={best_fs:.4f} at ({x0:.2f}, {y0:.2f})")
174
+
175
+ for iteration in range(max_iter):
176
+ print(f"[🔁 iteration {iteration+1}] center=({x0:.2f}, {y0:.2f}), FS={best_fs:.4f}, grid={grid_size:.4f}")
177
+ fs_cache, best_point = evaluate_grid(x0, y0, grid_size, depth_guess, slope_data, diagnostic=diagnostic, fs_cache=fs_cache)
178
+
179
+ if best_point['FS'] < best_fs:
180
+ best_fs = best_point['FS']
181
+ x0 = best_point['Xo']
182
+ y0 = best_point['Yo']
183
+ depth_guess = best_point['Depth']
184
+ search_path.append({"x": x0, "y": y0, "FS": best_fs})
185
+ else:
186
+ grid_size *= shrink_factor
187
+
188
+ if grid_size < tol:
189
+ converged = True
190
+ end_time = time.time()
191
+ elapsed = end_time - start_time
192
+ print(f"[✅ converged] Iter={iteration+1}, FS={best_fs:.4f} at (x={x0:.2f}, y={y0:.2f}, depth={depth_guess:.2f}), elapsed time={elapsed:.2f} seconds")
193
+ break
194
+
195
+ if not converged and diagnostic:
196
+ print(f"\n[❌ max iterations reached] FS={best_fs:.4f} at (x={x0:.2f}, y={y0:.2f})")
197
+
198
+ sorted_fs_cache = sorted(fs_cache.values(), key=lambda d: d['FS'])
199
+ return sorted_fs_cache, converged, search_path
200
+
201
+ def noncircular_search(slope_data, method_name, rapid=False, diagnostic=True, movement_distance=4.0, shrink_factor=0.8, fs_tol=0.001, max_iter=100, move_tol=0.1):
202
+ """
203
+ Non-circular search using the specified solver.
204
+
205
+ Parameters:
206
+ -----------
207
+ data : dict
208
+ Input data dictionary containing all necessary parameters
209
+ method_name : str
210
+ The method name to use (e.g., 'lowe_karafiath', 'spencer')
211
+ diagnostic : bool
212
+ If True, print diagnostic information during search
213
+ movement_distance : float
214
+ Initial distance to move points in each iteration
215
+ shrink_factor : float
216
+ Factor to reduce movement_distance by when no improvement is found
217
+ fs_tol : float
218
+ Factor of safety convergence tolerance
219
+ max_iter : int
220
+ Maximum number of iterations
221
+ move_tol : float
222
+ Minimum movement distance for convergence (AND logic with fs_tol)
223
+
224
+ Returns:
225
+ --------
226
+ tuple : (fs_cache, converged, search_path)
227
+ fs_cache : dict of all evaluated surfaces and their FS values
228
+ converged : bool indicating if search converged
229
+ search_path : list of surfaces evaluated during search
230
+ """
231
+ # Get the solver function from solve module
232
+ solver = getattr(solve, method_name)
233
+ def move_point(points, i, dx, dy, movement_type, ground_surface, max_depth):
234
+ """Move a point while respecting constraints"""
235
+ # Get current point
236
+ point = points[i]
237
+
238
+ # Calculate new position
239
+ new_x = point[0] + dx
240
+ new_y = point[1] + dy
241
+
242
+ # For endpoints, ensure they stay on ground surface
243
+ if i == 0 or i == len(points)-1:
244
+ # Create vertical line at new_x
245
+ vertical_line = LineString([(new_x, 0), (new_x, 1000)]) # Arbitrary high y value
246
+ intersection = ground_surface.intersection(vertical_line)
247
+ y = get_y_from_intersection(intersection)
248
+ if y is None:
249
+ return False
250
+ new_y = y
251
+ else:
252
+ # For middle points, ensure they stay below ground surface but above max_depth
253
+ if new_y > ground_surface.interpolate(ground_surface.project(Point(new_x, new_y))).y:
254
+ return False
255
+ if new_y < max_depth:
256
+ return False
257
+
258
+ # Check x-ordering constraints
259
+ if i > 0 and new_x <= points[i-1][0]: # Don't move past left neighbor
260
+ return False
261
+ if i < len(points)-1 and new_x >= points[i+1][0]: # Don't move past right neighbor
262
+ return False
263
+
264
+ # Update point
265
+ points[i] = [new_x, new_y]
266
+ return True
267
+
268
+ def evaluate_surface(points, distance, fs_cache=None):
269
+ """Evaluate factor of safety for current surface configuration"""
270
+ if fs_cache is None:
271
+ fs_cache = {}
272
+
273
+ # Create non_circ format from points
274
+ non_circ = [{'X': x, 'Y': y, 'Movement': movements[i]} for i, (x, y) in enumerate(points)]
275
+
276
+ # Generate slices and compute FS
277
+ success, result = generate_slices(slope_data, non_circ=non_circ)
278
+ if not success:
279
+ return float('inf'), None, None, None, fs_cache
280
+
281
+ df_slices, failure_surface = result
282
+ if rapid:
283
+ solver_success, solver_result = rapid_drawdown(df_slices, method_name)
284
+ else:
285
+ solver_success, solver_result = solver(df_slices)
286
+ FS = solver_result['FS'] if solver_success else float('inf')
287
+
288
+ # Cache result
289
+ key = tuple(map(tuple, points))
290
+ fs_cache[key] = {
291
+ 'points': points.copy(),
292
+ 'FS': FS,
293
+ 'slices': df_slices,
294
+ 'failure_surface': failure_surface,
295
+ 'solver_result': solver_result
296
+ }
297
+
298
+ return FS, df_slices, failure_surface, solver_result, fs_cache
299
+
300
+ # Get initial surface from non_circ data
301
+ non_circ = slope_data['non_circ']
302
+ points = np.array([[p['X'], p['Y']] for p in non_circ])
303
+ movements = [p['Movement'] for p in non_circ]
304
+ ground_surface = slope_data['ground_surface']
305
+
306
+ # Initialize cache and search path
307
+ fs_cache = {}
308
+ search_path = []
309
+
310
+ # Evaluate initial surface
311
+ FS, df_slices, failure_surface, solver_result, fs_cache = evaluate_surface(
312
+ points, movement_distance, fs_cache)
313
+
314
+ # Initialize best surface with initial evaluation
315
+ best_points = points.copy()
316
+ best_fs = FS
317
+ best_df = df_slices
318
+ best_surface = failure_surface
319
+ best_solver_result = solver_result
320
+
321
+ # Track convergence
322
+ converged = False
323
+ start_time = time.time()
324
+ prev_fs = best_fs
325
+
326
+ if diagnostic:
327
+ print(f"\n[✅ starting search] Initial FS={best_fs:.4f}\n")
328
+ print("Initial failure surface:")
329
+ for i, point in enumerate(points):
330
+ print(f"Point {i}: ({point[0]:.2f}, {point[1]:.2f})")
331
+ print("\nGround surface:")
332
+ for i, point in enumerate(ground_surface.coords):
333
+ print(f"Point {i}: ({point[0]:.2f}, {point[1]:.2f})")
334
+
335
+ # Main search loop
336
+ for iteration in range(max_iter):
337
+ improved = False
338
+
339
+ if diagnostic:
340
+ print(f"\nIteration {iteration + 1}")
341
+ print("Current surface points:")
342
+ for i, point in enumerate(best_surface.coords):
343
+ print(f"Point {i}: ({point[0]:.2f}, {point[1]:.2f})")
344
+
345
+ # Try moving each point
346
+ for i in range(len(points)):
347
+ # Try both positive and negative directions
348
+ for direction in [-1, 1]:
349
+ test_points = points.copy()
350
+
351
+ # Get movement direction based on point type
352
+ if i == 0 or i == len(points)-1: # End points
353
+ dx = direction * movement_distance
354
+ dy = 0 # y will be determined by ground surface
355
+ elif movements[i] == 'Horiz':
356
+ dx = direction * movement_distance
357
+ dy = 0
358
+ elif movements[i] == 'Free':
359
+ # For free points, move perpendicular to tangent
360
+ dx_tangent = points[i+1][0] - points[i-1][0] if i > 0 and i < len(points)-1 else 1
361
+ dy_tangent = points[i+1][1] - points[i-1][1] if i > 0 and i < len(points)-1 else 0
362
+ length = np.sqrt(dx_tangent**2 + dy_tangent**2)
363
+ if length > 0:
364
+ dx = -dy_tangent/length * direction * movement_distance
365
+ dy = dx_tangent/length * direction * movement_distance
366
+ else:
367
+ dx = direction * movement_distance
368
+ dy = 0
369
+ else: # Fixed
370
+ continue
371
+
372
+ # Try to move the point
373
+ if move_point(test_points, i, dx, dy, movements[i], ground_surface, slope_data['max_depth']):
374
+ # Evaluate new surface
375
+ FS, df_slices, failure_surface, solver_result, fs_cache = evaluate_surface(
376
+ test_points, movement_distance, fs_cache)
377
+
378
+ if FS < best_fs:
379
+ best_fs = FS
380
+ best_points = test_points.copy()
381
+ best_df = df_slices
382
+ best_surface = failure_surface
383
+ best_solver_result = solver_result
384
+ improved = True
385
+ if diagnostic:
386
+ print(f"[✓ improved] iter={iteration}, point={i}, FS={FS:.4f}")
387
+
388
+ # print iteration results
389
+ print(f"iteration {iteration+1} FS={best_fs:.4f}")
390
+
391
+ # Check convergence based on FS change and movement distance (AND logic)
392
+ fs_change = abs(best_fs - prev_fs)
393
+ if fs_change < fs_tol and movement_distance < move_tol:
394
+ converged = True
395
+ if diagnostic:
396
+ print(f"[✓ converged] FS change {fs_change:.6f} < tolerance {fs_tol} and movement_distance {movement_distance:.4f} < move_tol {move_tol}")
397
+ break
398
+ prev_fs = best_fs
399
+
400
+ if not improved or fs_change < fs_tol:
401
+ movement_distance *= shrink_factor
402
+ if True:
403
+ print(f"[↘️ shrinking] movement_distance={movement_distance:.4f}")
404
+
405
+ points = best_points.copy()
406
+
407
+ end_time = time.time()
408
+ elapsed = end_time - start_time
409
+
410
+ if converged:
411
+ print(f"\n[✅ converged] Iter={iteration+1}, FS={best_fs:.4f}, elapsed time={elapsed:.2f} seconds")
412
+ else:
413
+ print(f"\n[❌ max iterations reached] FS={best_fs:.4f}, elapsed time={elapsed:.2f} seconds")
414
+
415
+ sorted_fs_cache = sorted(fs_cache.values(), key=lambda d: d['FS'])
416
+ return sorted_fs_cache, converged, search_path