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/slice.py ADDED
@@ -0,0 +1,1075 @@
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
+ from math import sin, cos, tan, radians, atan, atan2, degrees, sqrt
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+ from shapely.geometry import LineString, Point, MultiPoint, GeometryCollection
20
+
21
+ from .mesh import find_element_containing_point, interpolate_at_point
22
+
23
+ def get_circular_y_coordinates(x_coords, Xo, Yo, R):
24
+ """
25
+ Calculate y-coordinates on a circular failure surface for given x-coordinates.
26
+
27
+ Parameters:
28
+ x_coords (array-like): X-coordinates to evaluate
29
+ Xo, Yo (float): Center coordinates of the circle
30
+ R (float): Radius of the circle
31
+
32
+ Returns:
33
+ numpy.ndarray: Y-coordinates on the circle (bottom half)
34
+ """
35
+ x_coords = np.asarray(x_coords)
36
+ # Calculate y-coordinates for the bottom half of the circle
37
+ # y = Yo - sqrt(R^2 - (x - Xo)^2)
38
+ dx_squared = (x_coords - Xo) ** 2
39
+ # Handle cases where x is outside the circle
40
+ valid_mask = dx_squared <= R ** 2
41
+ y_coords = np.full_like(x_coords, np.nan)
42
+ y_coords[valid_mask] = Yo - np.sqrt(R ** 2 - dx_squared[valid_mask])
43
+ return y_coords
44
+
45
+
46
+ def get_circular_intersection_points(ground_surface, Xo, Yo, R, x_min, x_max):
47
+ """
48
+ Find intersection points between a circular failure surface and ground surface.
49
+
50
+ Parameters:
51
+ ground_surface (LineString): Ground surface geometry
52
+ Xo, Yo, R (float): Circle parameters
53
+ x_min, x_max (float): X-range to search
54
+
55
+ Returns:
56
+ tuple: (x_min, x_max, y_left, y_right, success)
57
+ """
58
+ # Create a dense set of points on the circle for intersection testing
59
+ x_test = np.linspace(x_min, x_max, 1000)
60
+ y_circle = get_circular_y_coordinates(x_test, Xo, Yo, R)
61
+
62
+ # Create circle line for intersection
63
+ valid_mask = ~np.isnan(y_circle)
64
+ if not np.any(valid_mask):
65
+ return None, None, None, None, False
66
+
67
+ circle_coords = list(zip(x_test[valid_mask], y_circle[valid_mask]))
68
+ circle_line = LineString(circle_coords)
69
+
70
+ # Find intersections
71
+ intersections = circle_line.intersection(ground_surface)
72
+
73
+ if isinstance(intersections, Point):
74
+ points = [intersections]
75
+ elif isinstance(intersections, MultiPoint):
76
+ points = list(intersections.geoms)
77
+ elif isinstance(intersections, GeometryCollection):
78
+ points = [g for g in intersections.geoms if isinstance(g, Point)]
79
+ else:
80
+ points = []
81
+
82
+ if len(points) < 2:
83
+ return None, None, None, None, False
84
+
85
+ # Sort by x and take the two endpoints
86
+ points = sorted(points, key=lambda p: p.x)
87
+ x_min, x_max = points[0].x, points[-1].x
88
+ y_left, y_right = points[0].y, points[-1].y
89
+
90
+ return x_min, x_max, y_left, y_right, True
91
+
92
+
93
+ def get_ground_surface_y_coordinates(x_coords, ground_surface):
94
+ """
95
+ Get y-coordinates on the ground surface for given x-coordinates using interpolation.
96
+
97
+ Parameters:
98
+ x_coords (array-like): X-coordinates to evaluate
99
+ ground_surface (LineString): Ground surface geometry
100
+
101
+ Returns:
102
+ numpy.ndarray: Y-coordinates on the ground surface
103
+ """
104
+ x_coords = np.asarray(x_coords)
105
+ ground_coords = np.array(ground_surface.coords)
106
+ ground_x = ground_coords[:, 0]
107
+ ground_y = ground_coords[:, 1]
108
+
109
+ # Sort by x to ensure proper interpolation
110
+ sort_idx = np.argsort(ground_x)
111
+ ground_x = ground_x[sort_idx]
112
+ ground_y = ground_y[sort_idx]
113
+
114
+ # Interpolate y-coordinates
115
+ y_coords = np.interp(x_coords, ground_x, ground_y, left=np.nan, right=np.nan)
116
+ return y_coords
117
+
118
+
119
+ def get_profile_layer_y_coordinates(x_coords, profile_lines):
120
+ """
121
+ Get y-coordinates for each profile layer at given x-coordinates.
122
+
123
+ Parameters:
124
+ x_coords (array-like): X-coordinates to evaluate
125
+ profile_lines (list): List of profile layer lines
126
+
127
+ Returns:
128
+ list: List of arrays, each containing y-coordinates for a profile layer
129
+ """
130
+ x_coords = np.asarray(x_coords)
131
+ layer_y_coords = []
132
+
133
+ for line in profile_lines:
134
+ line_coords = np.array(line)
135
+ line_x = line_coords[:, 0]
136
+ line_y = line_coords[:, 1]
137
+
138
+ # Sort by x to ensure proper interpolation
139
+ sort_idx = np.argsort(line_x)
140
+ line_x = line_x[sort_idx]
141
+ line_y = line_y[sort_idx]
142
+
143
+ # Interpolate y-coordinates
144
+ y_coords = np.interp(x_coords, line_x, line_y, left=np.nan, right=np.nan)
145
+ layer_y_coords.append(y_coords)
146
+
147
+ return layer_y_coords
148
+
149
+
150
+ def get_piezometric_y_coordinates(x_coords, piezo_line):
151
+ """
152
+ Get y-coordinates on the piezometric surface for given x-coordinates.
153
+
154
+ Parameters:
155
+ x_coords (array-like): X-coordinates to evaluate
156
+ piezo_line (list): Piezometric line coordinates
157
+
158
+ Returns:
159
+ numpy.ndarray: Y-coordinates on the piezometric surface
160
+ """
161
+ if not piezo_line:
162
+ return np.full_like(x_coords, np.nan)
163
+
164
+ x_coords = np.asarray(x_coords)
165
+ piezo_coords = np.array(piezo_line)
166
+ piezo_x = piezo_coords[:, 0]
167
+ piezo_y = piezo_coords[:, 1]
168
+
169
+ # Sort by x to ensure proper interpolation
170
+ sort_idx = np.argsort(piezo_x)
171
+ piezo_x = piezo_x[sort_idx]
172
+ piezo_y = piezo_y[sort_idx]
173
+
174
+ # Interpolate y-coordinates
175
+ y_coords = np.interp(x_coords, piezo_x, piezo_y, left=np.nan, right=np.nan)
176
+ return y_coords
177
+
178
+
179
+ def circle_polyline_intersections(Xo, Yo, R, polyline):
180
+ """
181
+ Find intersection points between the bottom half of a circle and a polyline (LineString).
182
+ Returns a list of shapely Point objects.
183
+ """
184
+ intersections = []
185
+ coords = list(polyline.coords)
186
+ for i in range(len(coords) - 1):
187
+ x1, y1 = coords[i]
188
+ x2, y2 = coords[i+1]
189
+ dx = x2 - x1
190
+ dy = y2 - y1
191
+
192
+ # Quadratic coefficients for t
193
+ a = dx**2 + dy**2
194
+ b = 2 * (dx * (x1 - Xo) + dy * (y1 - Yo))
195
+ c = (x1 - Xo)**2 + (y1 - Yo)**2 - R**2
196
+
197
+ discriminant = b**2 - 4*a*c
198
+ if discriminant < 0:
199
+ continue # No intersection
200
+
201
+ sqrt_disc = np.sqrt(discriminant)
202
+ for sign in [-1, 1]:
203
+ t = (-b + sign * sqrt_disc) / (2 * a)
204
+ if 0 <= t <= 1:
205
+ xi = x1 + t * dx
206
+ yi = y1 + t * dy
207
+ if yi < Yo: # Only keep points below the center (bottom half)
208
+ intersections.append(Point(xi, yi))
209
+ return intersections
210
+
211
+
212
+ def get_sorted_intersections(failure_surface, ground_surface, circle_params=None):
213
+ """
214
+ Find and sort the intersection points between the failure and ground surfaces,
215
+ pruning extras if the circle exits and re-enters the ground beyond the toe.
216
+ If circle_params is provided, use analytic intersection.
217
+ Returns:
218
+ success (bool), msg (str), points (list of shapely Point)
219
+ """
220
+ if circle_params is not None:
221
+ Xo, Yo, R = circle_params['Xo'], circle_params['Yo'], circle_params['R']
222
+ points = circle_polyline_intersections(Xo, Yo, R, ground_surface)
223
+ else:
224
+ intersections = failure_surface.intersection(ground_surface)
225
+ if isinstance(intersections, MultiPoint):
226
+ points = list(intersections.geoms)
227
+ elif isinstance(intersections, Point):
228
+ points = [intersections]
229
+ elif isinstance(intersections, GeometryCollection):
230
+ points = [g for g in intersections.geoms if isinstance(g, Point)]
231
+ else:
232
+ points = []
233
+
234
+ # need at least two
235
+ if len(points) < 2:
236
+ return False, f"Expected at least 2 intersection points, but got {len(points)}.", None
237
+
238
+ # sort by x
239
+ points = sorted(points, key=lambda p: p.x)
240
+
241
+ # if exactly two, we're done
242
+ if len(points) == 2:
243
+ left, right = points[0], points[1]
244
+ tol = 1e-6
245
+ if abs(left.y - right.y) < tol:
246
+ return False, "Rejected: left-most and right-most intersection points have the same y-value (flat arc).", None
247
+ return True, "", points
248
+
249
+ # more than two: decide facing
250
+ y_first, y_last = points[0].y, points[-1].y
251
+ if y_first > y_last:
252
+ # right-facing: keep first two
253
+ pruned = points[:2]
254
+ else:
255
+ # left-facing: keep last two
256
+ pruned = points[-2:]
257
+
258
+ # sort those two again by x (just in case)
259
+ pruned = sorted(pruned, key=lambda p: p.x)
260
+ left, right = pruned[0], pruned[1]
261
+ tol = 1e-6
262
+ if abs(left.y - right.y) < tol:
263
+ return False, "Rejected: left-most and right-most intersection points have the same y-value (flat arc).", None
264
+ return True, "", pruned
265
+
266
+
267
+ def adjust_ground_for_tcrack(ground_surface, x_center, tcrack_depth, right_facing):
268
+ # helper function to adjust the ground surface for tension crack
269
+ if tcrack_depth <= 0:
270
+ return ground_surface
271
+
272
+ new_coords = []
273
+ for x, y in ground_surface.coords:
274
+ if right_facing and x < x_center:
275
+ new_coords.append((x, y - tcrack_depth))
276
+ elif not right_facing and x > x_center:
277
+ new_coords.append((x, y - tcrack_depth))
278
+ else:
279
+ new_coords.append((x, y))
280
+ return LineString(new_coords)
281
+
282
+
283
+ def generate_failure_surface(ground_surface, circular, circle=None, non_circ=None, tcrack_depth=0):
284
+ """
285
+ Generates a failure surface based on either a circular or non-circular definition.
286
+
287
+ Parameters:
288
+ ground_surface (LineString): The ground surface geometry.
289
+ circular (bool): Whether to use circular failure surface.
290
+ circle (dict, optional): Dictionary with keys 'Xo', 'Yo', 'Depth', and 'R'.
291
+ non_circ (list, optional): List of dicts with keys 'X', 'Y', and 'Movement'.
292
+ tcrack_depth (float, optional): Tension crack depth.
293
+
294
+ Returns:
295
+ tuple: (success, result)
296
+ - If success is True:
297
+ result = (x_min, x_max, y_left, y_right, clipped_surface)
298
+ - If success is False:
299
+ result = error message string
300
+ """
301
+ # --- Step 1: Build failure surface ---
302
+ if circular and circle:
303
+ Xo, Yo, depth, R = circle['Xo'], circle['Yo'], circle['Depth'], circle['R']
304
+ theta_range = np.linspace(np.pi, 2 * np.pi, 100)
305
+ arc = [(Xo + R * np.cos(t), Yo + R * np.sin(t)) for t in theta_range]
306
+ failure_coords = arc
307
+ failure_surface = LineString(arc)
308
+ elif non_circ:
309
+ failure_coords = [(pt['X'], pt['Y']) for pt in non_circ]
310
+ failure_surface = LineString(failure_coords)
311
+ else:
312
+ return False, "Either a circular or non-circular failure surface must be provided."
313
+
314
+ # --- Step 2: Intersect with original ground surface to determine slope facing ---
315
+ if circular and circle:
316
+ success, msg, points = get_sorted_intersections(failure_surface, ground_surface, circle_params=circle)
317
+ else:
318
+ success, msg, points = get_sorted_intersections(failure_surface, ground_surface)
319
+ if not success:
320
+ return False, msg
321
+
322
+ x_min, x_max = points[0].x, points[1].x
323
+ y_left, y_right = points[0].y, points[1].y
324
+ right_facing = y_left > y_right
325
+ x_center = 0.5 * (x_min + x_max)
326
+
327
+ # --- Step 3: If tension crack exists, adjust surface and re-intersect ---
328
+ if tcrack_depth > 0:
329
+ modified_surface = adjust_ground_for_tcrack(ground_surface, x_center, tcrack_depth, right_facing)
330
+ if circular and circle:
331
+ success, msg, points = get_sorted_intersections(failure_surface, modified_surface, circle_params=circle)
332
+ else:
333
+ success, msg, points = get_sorted_intersections(failure_surface, modified_surface)
334
+ if not success:
335
+ return False, msg
336
+ x_min, x_max = points[0].x, points[1].x
337
+ y_left, y_right = points[0].y, points[1].y
338
+
339
+ # --- Step 4: Clip the failure surface between intersection x-range ---
340
+ # Filter coordinates within the x-range
341
+ filtered_coords = [pt for pt in failure_coords if x_min <= pt[0] <= x_max]
342
+
343
+ # Add the exact intersection points if they're not already in the filtered list
344
+ left_intersection = (x_min, y_left)
345
+ right_intersection = (x_max, y_right)
346
+
347
+ # Check if intersection points are already in the filtered list (with tolerance)
348
+ tol = 1e-6
349
+ has_left = any(abs(pt[0] - x_min) < tol and abs(pt[1] - y_left) < tol for pt in filtered_coords)
350
+ has_right = any(abs(pt[0] - x_max) < tol and abs(pt[1] - y_right) < tol for pt in filtered_coords)
351
+
352
+ if not has_left:
353
+ filtered_coords.insert(0, left_intersection)
354
+ if not has_right:
355
+ filtered_coords.append(right_intersection)
356
+
357
+ # Sort by x-coordinate to ensure proper ordering
358
+ filtered_coords.sort(key=lambda pt: pt[0])
359
+
360
+ clipped_surface = LineString(filtered_coords)
361
+
362
+ return True, (x_min, x_max, y_left, y_right, clipped_surface)
363
+
364
+
365
+ def get_y_from_intersection(geom):
366
+ """
367
+ Extracts the maximum Y-coordinate from a geometric intersection result.
368
+
369
+ This function handles different geometric types resulting from intersections,
370
+ including Point, MultiPoint, LineString, and GeometryCollection. If the input
371
+ geometry is not one of these or is empty, the function returns None.
372
+
373
+ Parameters:
374
+ geom (shapely.geometry.base.BaseGeometry): The geometry object from which
375
+ to extract the Y-coordinate(s).
376
+
377
+ Returns:
378
+ float or None: The maximum Y-coordinate found in the geometry, or None if not found.
379
+ """
380
+ if isinstance(geom, Point):
381
+ return geom.y
382
+ elif isinstance(geom, MultiPoint):
383
+ return max(pt.y for pt in geom.geoms)
384
+ elif isinstance(geom, LineString):
385
+ return max(y for _, y in geom.coords) if geom.coords else None
386
+ elif isinstance(geom, GeometryCollection):
387
+ pts = [g for g in geom.geoms if isinstance(g, Point)]
388
+ return max(pt.y for pt in pts) if pts else None
389
+ return None
390
+
391
+
392
+ def calc_dload_resultant(x_l, y_lt, x_r, y_rt, qL, qR, dl):
393
+ """
394
+ Compute:
395
+ - D : total resultant force from a trapezoidal load varying
396
+ linearly from intensity qL at (x_l,y_lt) to qR at (x_r,y_rt).
397
+ - d_x : x‐coordinate of the resultant's centroid on the top edge
398
+ - d_y : y‐coordinate of the resultant's centroid on the top edge
399
+
400
+ Parameters
401
+ ----------
402
+ x_l, y_lt : float
403
+ Coordinates of the left‐end of the top edge.
404
+ x_r, y_rt : float
405
+ Coordinates of the right‐end of the top edge.
406
+ qL : float
407
+ Load intensity (force per unit length) at (x_l, y_lt).
408
+ qR : float
409
+ Load intensity (force per unit length) at (x_r, y_rt).
410
+ dl : float
411
+ Actual length along the inclined surface.
412
+
413
+ Returns
414
+ -------
415
+ D : float
416
+ Total resultant (area of trapezoid) = ½ (qL + qR) * dl
417
+ d_x : float
418
+ Global x‐coordinate of the centroid of that trapezoid
419
+ d_y : float
420
+ Global y‐coordinate of the centroid (lies on the line segment
421
+ between (x_l,y_lt) and (x_r,y_rt))
422
+
423
+ Notes
424
+ -----
425
+ 1. If x_r == x_l (zero‐width slice), this will return D=0 and place
426
+ the "centroid" at (x_l, y_lt).
427
+ 2. For a nonzero‐width trapezoid, the horizontal centroid‐offset from
428
+ x_l is:
429
+ x_offset = (x_r – x_l) * ( qL + 2 qR ) / [3 (qL + qR) ]
430
+ provided (qL + qR) ≠ 0. If qL + qR ≈ 0, it simply places the
431
+ centroid at the midpoint in x.
432
+ 3. The vertical coordinate d_y is found by linear‐interpolation:
433
+ t = x_offset / (x_r – x_l)
434
+ d_y = y_lt + t ·(y_rt – y_lt)
435
+
436
+ """
437
+ dx = x_r - x_l
438
+
439
+ # 1) Total resultant force (area under trapezoid) using actual length
440
+ D = 0.5 * (qL + qR) * dl
441
+
442
+ # 2) Horizontal centroid offset from left end
443
+ sum_q = qL + qR
444
+ if abs(sum_q) < 1e-12:
445
+ # nearly zero trapezoid => centroid at geometric midpoint
446
+ x_offset = dx * 0.5
447
+ else:
448
+ x_offset = dx * (qL + 2.0 * qR) / (3.0 * sum_q)
449
+
450
+ # 3) Global x‐coordinate of centroid
451
+ d_x = x_l + x_offset
452
+
453
+ # 4) Corresponding y‐coordinate by linear interpolation along top edge
454
+ t = x_offset / dx
455
+ d_y = y_lt + t * (y_rt - y_lt)
456
+
457
+ return D, d_x, d_y
458
+
459
+
460
+ def generate_slices(slope_data, circle=None, non_circ=None, num_slices=40, debug=True):
461
+
462
+ """
463
+ Generates vertical slices between the ground surface and a failure surface for slope stability analysis.
464
+
465
+ This function supports both circular and non-circular failure surfaces and computes
466
+ geometric and mechanical properties for each slice, including weight, base geometry,
467
+ water pressures, distributed loads, and reinforcement effects.
468
+
469
+ Parameters:
470
+ data (dict): Dictionary containing all input data
471
+ circle (dict, optional): Dictionary with keys 'Xo', 'Yo', 'Depth', and 'R' defining the circular failure surface.
472
+ non_circ (list, optional): List of dicts defining a non-circular failure surface with keys 'X', 'Y', and 'Movement'.
473
+ num_slices (int, optional): Desired number of slices to generate (default is 40).
474
+ debug (bool, optional): Whether to print debug information (default is True).
475
+
476
+ Returns:
477
+ tuple:
478
+ - pd.DataFrame: Slice table where each row includes geometry, strength, and external force values.
479
+ - shapely.geometry.LineString: The clipped failure surface between the ground surface intersections.
480
+
481
+ Notes:
482
+ - Supports Method A interpretation of reinforcement: T reduces driving forces.
483
+ - Handles pore pressure and distributed loads using linear interpolation at slice centers.
484
+ - Automatically includes all geometry breakpoints in slice generation.
485
+ - Must specify exactly one of 'circle' or 'non_circ'.
486
+ """
487
+
488
+ # Unpack data
489
+ profile_lines = slope_data["profile_lines"]
490
+ ground_surface = slope_data["ground_surface"]
491
+ materials = slope_data["materials"]
492
+ piezo_line = slope_data["piezo_line"]
493
+ piezo_line2 = slope_data.get("piezo_line2", []) # Second piezometric line
494
+ gamma_w = slope_data["gamma_water"]
495
+ tcrack_depth = slope_data["tcrack_depth"]
496
+ tcrack_water = slope_data["tcrack_water"]
497
+ k_seismic = slope_data['k_seismic']
498
+ dloads = slope_data["dloads"]
499
+ dloads2 = slope_data.get("dloads2", [])
500
+ max_depth = slope_data["max_depth"]
501
+
502
+ # Determine failure surface type
503
+ if circle is not None:
504
+ circular = True
505
+ Xo, Yo, depth, R = circle['Xo'], circle['Yo'], circle['Depth'], circle['R']
506
+ else:
507
+ circular = False
508
+
509
+ # Prepare reinforcement lines data
510
+ reinf_lines_data = []
511
+ if slope_data.get("reinforce_lines"):
512
+ for line in slope_data["reinforce_lines"]:
513
+ xs = [pt["X"] for pt in line]
514
+ ts = [pt["T"] for pt in line]
515
+ geom = LineString([(pt["X"], pt["Y"]) for pt in line])
516
+ reinf_lines_data.append({"xs": xs, "ts": ts, "geom": geom})
517
+
518
+ ground_surface = LineString([(x, y) for x, y in ground_surface.coords])
519
+
520
+ # Generate failure surface
521
+ success, result = generate_failure_surface(ground_surface, circular, circle=circle, non_circ=non_circ, tcrack_depth=tcrack_depth)
522
+ if success:
523
+ x_min, x_max, y_left, y_right, clipped_surface = result
524
+ else:
525
+ return False, "Failed to generate surface:" + result
526
+
527
+ # Determine if the failure surface is right-facing
528
+ right_facing = y_left > y_right
529
+
530
+ # === BEGIN : Find set of points that should correspond to slice boundaries. ===
531
+
532
+ # Find set of points that are on the profile lines if the points are above the failure surface.
533
+ fixed_xs = set()
534
+
535
+ # Vectorized approach for profile line points
536
+ for line in profile_lines:
537
+ line_coords = np.array(line)
538
+ x_coords = line_coords[:, 0]
539
+ y_coords = line_coords[:, 1]
540
+
541
+ # Filter points within x-range
542
+ mask = (x_coords >= x_min) & (x_coords <= x_max)
543
+ x_filtered = x_coords[mask]
544
+ y_filtered = y_coords[mask]
545
+
546
+ if len(x_filtered) == 0:
547
+ continue
548
+
549
+ # Check if points are above the failure surface
550
+ if circular:
551
+ # Use parametric equation for circular failure surface
552
+ failure_y = get_circular_y_coordinates(x_filtered, Xo, Yo, R)
553
+ above_mask = y_filtered > failure_y
554
+ else:
555
+ # For non-circular, use geometric intersection (slower but necessary)
556
+ above_mask = np.zeros(len(x_filtered), dtype=bool)
557
+ for i, (x, y) in enumerate(zip(x_filtered, y_filtered)):
558
+ vertical_line = LineString([(x, -1e6), (x, 1e6)])
559
+ failure_y = get_y_from_intersection(clipped_surface.intersection(vertical_line))
560
+ if failure_y is not None and y > failure_y:
561
+ above_mask[i] = True
562
+
563
+ # Add points that are above the failure surface
564
+ fixed_xs.update(x_filtered[above_mask])
565
+
566
+ fixed_xs.update([x_min, x_max])
567
+
568
+ # Add transition points from dloads
569
+ if dloads:
570
+ fixed_xs.update(
571
+ pt['X'] for line in dloads for pt in line
572
+ if x_min <= pt['X'] <= x_max
573
+ )
574
+
575
+ # Add transition points from dloads2
576
+ if dloads2:
577
+ fixed_xs.update(
578
+ pt['X'] for line in dloads2 for pt in line
579
+ if x_min <= pt['X'] <= x_max
580
+ )
581
+
582
+ # Add transition points from non_circ
583
+ if non_circ:
584
+ fixed_xs.update(
585
+ pt['X'] for pt in non_circ
586
+ if x_min <= pt['X'] <= x_max
587
+ )
588
+
589
+ # Find intersections with profile lines and failure surface
590
+ if circular:
591
+ # For circular failure surfaces, we can use a more efficient approach
592
+ # by creating a dense circle representation and finding intersections
593
+ for line in profile_lines:
594
+ line_geom = LineString(line)
595
+ # Create a dense circle representation for intersection
596
+ theta_range = np.linspace(np.pi, 2 * np.pi, 200)
597
+ circle_coords = [(Xo + R * np.cos(t), Yo + R * np.sin(t)) for t in theta_range]
598
+ circle_line = LineString(circle_coords)
599
+
600
+ intersection = line_geom.intersection(circle_line)
601
+ if not intersection.is_empty:
602
+ if hasattr(intersection, 'x'):
603
+ fixed_xs.add(intersection.x)
604
+ elif hasattr(intersection, 'geoms'):
605
+ for geom in intersection.geoms:
606
+ if hasattr(geom, 'x'):
607
+ fixed_xs.add(geom.x)
608
+ else:
609
+ # For non-circular failure surfaces, use the original approach
610
+ for i in range(len(profile_lines)):
611
+ intersection = LineString(profile_lines[i]).intersection(clipped_surface)
612
+ if not intersection.is_empty:
613
+ if hasattr(intersection, 'x'):
614
+ fixed_xs.add(intersection.x)
615
+ elif hasattr(intersection, 'geoms'):
616
+ for geom in intersection.geoms:
617
+ if hasattr(geom, 'x'):
618
+ fixed_xs.add(geom.x)
619
+
620
+ # Find intersections with piezometric lines
621
+ if piezo_line:
622
+ piezo_geom1 = LineString(piezo_line)
623
+ if circular:
624
+ # Use dense circle representation for intersection
625
+ theta_range = np.linspace(np.pi, 2 * np.pi, 200)
626
+ circle_coords = [(Xo + R * np.cos(t), Yo + R * np.sin(t)) for t in theta_range]
627
+ circle_line = LineString(circle_coords)
628
+ intersection1 = piezo_geom1.intersection(circle_line)
629
+ else:
630
+ intersection1 = piezo_geom1.intersection(clipped_surface)
631
+
632
+ if not intersection1.is_empty:
633
+ if hasattr(intersection1, 'x'):
634
+ # Single point intersection
635
+ if x_min <= intersection1.x <= x_max:
636
+ fixed_xs.add(intersection1.x)
637
+ elif hasattr(intersection1, 'geoms'):
638
+ # Multiple points or line intersection
639
+ for geom in intersection1.geoms:
640
+ if hasattr(geom, 'x') and x_min <= geom.x <= x_max:
641
+ fixed_xs.add(geom.x)
642
+
643
+ if piezo_line2:
644
+ piezo_geom2 = LineString(piezo_line2)
645
+ if circular:
646
+ theta_range = np.linspace(np.pi, 2 * np.pi, 200)
647
+ circle_coords = [(Xo + R * np.cos(t), Yo + R * np.sin(t)) for t in theta_range]
648
+ circle_line = LineString(circle_coords)
649
+ intersection2 = piezo_geom2.intersection(circle_line)
650
+ else:
651
+ intersection2 = piezo_geom2.intersection(clipped_surface)
652
+
653
+ if not intersection2.is_empty:
654
+ if hasattr(intersection2, 'x'):
655
+ # Single point intersection
656
+ if x_min <= intersection2.x <= x_max:
657
+ fixed_xs.add(intersection2.x)
658
+ elif hasattr(intersection2, 'geoms'):
659
+ # Multiple points or line intersection
660
+ for geom in intersection2.geoms:
661
+ if hasattr(geom, 'x') and x_min <= geom.x <= x_max:
662
+ fixed_xs.add(geom.x)
663
+
664
+ # Remove duplicate points that are very close to each other
665
+ tolerance = 1e-6
666
+ cleaned_xs = []
667
+ for x in sorted(fixed_xs):
668
+ if not cleaned_xs or abs(x - cleaned_xs[-1]) > tolerance:
669
+ cleaned_xs.append(x)
670
+
671
+ fixed_xs = cleaned_xs
672
+
673
+ # Generate slice boundaries
674
+ segment_lengths = [fixed_xs[i + 1] - fixed_xs[i] for i in range(len(fixed_xs) - 1)]
675
+ total_length = sum(segment_lengths)
676
+ all_xs = [fixed_xs[0]]
677
+ for i in range(len(fixed_xs) - 1):
678
+ x_start = fixed_xs[i]
679
+ x_end = fixed_xs[i + 1]
680
+ segment_length = x_end - x_start
681
+ n_subdiv = max(1, int(round((segment_length / total_length) * num_slices)))
682
+ xs = np.linspace(x_start, x_end, n_subdiv + 1).tolist()
683
+ all_xs.extend(xs[1:])
684
+
685
+ # === END : Find set of points that should correspond to slice boundaries. ===
686
+
687
+ # Remove thin slices (width < 1e-2), including at the ends
688
+ min_width = 1e-2
689
+ cleaned_xs = [all_xs[0]]
690
+ for x in all_xs[1:]:
691
+ if abs(x - cleaned_xs[-1]) >= min_width:
692
+ cleaned_xs.append(x)
693
+
694
+ # If the last slice is thin, merge it with the previous one
695
+ if len(cleaned_xs) > 2 and abs(cleaned_xs[-1] - cleaned_xs[-2]) < min_width:
696
+ cleaned_xs.pop(-2)
697
+
698
+ all_xs = cleaned_xs
699
+
700
+ # Pre-compute all y-coordinates for efficiency
701
+ slice_x_coords = np.array(all_xs)
702
+ slice_centers = (slice_x_coords[:-1] + slice_x_coords[1:]) / 2
703
+
704
+ # Get failure surface y-coordinates
705
+ if circular:
706
+ # Use parametric equations for circular failure surface
707
+ y_lb_all = get_circular_y_coordinates(slice_x_coords[:-1], Xo, Yo, R)
708
+ y_rb_all = get_circular_y_coordinates(slice_x_coords[1:], Xo, Yo, R)
709
+ y_cb_all = get_circular_y_coordinates(slice_centers, Xo, Yo, R)
710
+ else:
711
+ # For non-circular, we need to use geometric intersections
712
+ y_lb_all = np.array([get_y_from_intersection(clipped_surface.intersection(LineString([(x, -1e6), (x, 1e6)]))) for x in slice_x_coords[:-1]])
713
+ y_rb_all = np.array([get_y_from_intersection(clipped_surface.intersection(LineString([(x, -1e6), (x, 1e6)]))) for x in slice_x_coords[1:]])
714
+ y_cb_all = np.array([get_y_from_intersection(clipped_surface.intersection(LineString([(x, -1e6), (x, 1e6)]))) for x in slice_centers])
715
+
716
+ # Get ground surface y-coordinates
717
+ y_lt_all = get_ground_surface_y_coordinates(slice_x_coords[:-1], ground_surface)
718
+ y_rt_all = get_ground_surface_y_coordinates(slice_x_coords[1:], ground_surface)
719
+ y_ct_all = get_ground_surface_y_coordinates(slice_centers, ground_surface)
720
+
721
+ # Get profile layer y-coordinates
722
+ profile_y_coords = get_profile_layer_y_coordinates(slice_centers, profile_lines)
723
+
724
+ # Get piezometric y-coordinates
725
+ piezo_y_all = get_piezometric_y_coordinates(slice_centers, piezo_line)
726
+ piezo_y2_all = get_piezometric_y_coordinates(slice_centers, piezo_line2)
727
+
728
+ # Interpolation functions for distributed loads
729
+ dload_interp_funcs = []
730
+ if dloads:
731
+ for line in dloads:
732
+ xs = [pt['X'] for pt in line]
733
+ normals = [pt['Normal'] for pt in line]
734
+ dload_interp_funcs.append(lambda x, xs=xs, normals=normals: np.interp(x, xs, normals, left=0, right=0))
735
+
736
+ # Interpolation functions for second set of distributed loads
737
+ dload2_interp_funcs = []
738
+ if dloads2:
739
+ for line in dloads2:
740
+ xs = [pt['X'] for pt in line]
741
+ normals = [pt['Normal'] for pt in line]
742
+ dload2_interp_funcs.append(lambda x, xs=xs, normals=normals: np.interp(x, xs, normals, left=0, right=0))
743
+
744
+ # Generate slices
745
+ slices = []
746
+ for i in range(len(all_xs) - 1):
747
+ x_l, x_r = slice_x_coords[i], slice_x_coords[i + 1]
748
+ x_c = slice_centers[i]
749
+ dx = x_r - x_l
750
+
751
+ # Get pre-computed y-coordinates
752
+ y_lb = y_lb_all[i]
753
+ y_rb = y_rb_all[i]
754
+ y_cb = y_cb_all[i]
755
+ y_lt = y_lt_all[i]
756
+ y_rt = y_rt_all[i]
757
+ y_ct = y_ct_all[i]
758
+
759
+ # Skip if any coordinates are invalid
760
+ if any(np.isnan([y_lb, y_rb, y_cb, y_lt, y_rt, y_ct])):
761
+ continue
762
+
763
+ # Calculate beta (slope angle of the top edge) in degrees
764
+ beta = degrees(atan2(y_rt - y_lt, x_r - x_l))
765
+ if right_facing:
766
+ beta = -beta
767
+
768
+ # Calculate dl for the top surface (for distributed loads)
769
+ dl_top = sqrt((x_r - x_l)**2 + (y_rt - y_lt)**2)
770
+
771
+ # Calculate layer heights using pre-computed profile coordinates
772
+ heights = []
773
+ soil_weight = 0
774
+ base_material_idx = None
775
+ sum_gam_h_y = 0 # for calculating center of gravity of slice
776
+ sum_gam_h = 0 # ditto
777
+
778
+ for mat_index, layer_y in enumerate(profile_y_coords):
779
+ layer_top_y = layer_y[i]
780
+
781
+ # Bottom: highest of all other profile lines at x, or failure surface
782
+ layer_bot_y = y_cb # Start with failure surface as default bottom
783
+ for j in range(mat_index + 1, len(profile_y_coords)):
784
+ next_y = profile_y_coords[j][i]
785
+ if not np.isnan(next_y) and next_y > layer_bot_y:
786
+ # Take the highest of the lower profile lines
787
+ layer_bot_y = next_y
788
+
789
+ if np.isnan(layer_top_y) or np.isnan(layer_bot_y):
790
+ h = 0
791
+ else:
792
+ overlap_top = min(y_ct, layer_top_y)
793
+ overlap_bot = max(y_cb, layer_bot_y)
794
+ h = max(0, overlap_top - overlap_bot)
795
+ sum_gam_h_y += h * materials[mat_index]['gamma'] * (overlap_top + overlap_bot) / 2
796
+ sum_gam_h += h * materials[mat_index]['gamma']
797
+
798
+ heights.append(h)
799
+ soil_weight += h * materials[mat_index]['gamma'] * dx
800
+
801
+ if h > 0:
802
+ base_material_idx = mat_index
803
+
804
+ # Center of gravity
805
+ y_cg = (sum_gam_h_y) / sum_gam_h if sum_gam_h > 0 else None
806
+
807
+ # Distributed load
808
+ qC = sum(func(x_c) for func in dload_interp_funcs) if dload_interp_funcs else 0 # intensity at center
809
+ if qC > 0: # We need to check qC to distinguish between a linear ramp up (down) and the case where the load starts or ends on one of the sides
810
+ qL = sum(func(x_l) for func in dload_interp_funcs) if dload_interp_funcs else 0 # intensity at left‐top corner
811
+ qR = sum(func(x_r) for func in dload_interp_funcs) if dload_interp_funcs else 0 # intensity at right‐top corner
812
+ else:
813
+ qL = 0
814
+ qR = 0
815
+ dload, d_x, d_y = calc_dload_resultant(x_l, y_lt, x_r, y_rt, qL, qR, dl_top)
816
+
817
+ # Second distributed load
818
+ qC2 = sum(func(x_c) for func in dload2_interp_funcs) if dload2_interp_funcs else 0 # intensity at center
819
+ if qC2 > 0: # We need to check qC2 to distinguish between a linear ramp up (down) and the case where the load starts or ends on one of the sides
820
+ qL2 = sum(func(x_l) for func in dload2_interp_funcs) if dload2_interp_funcs else 0 # intensity at left‐top corner
821
+ qR2 = sum(func(x_r) for func in dload2_interp_funcs) if dload2_interp_funcs else 0 # intensity at right‐top corner
822
+ else:
823
+ qL2 = 0
824
+ qR2 = 0
825
+ dload2, d_x2, d_y2 = calc_dload_resultant(x_l, y_lt, x_r, y_rt, qL2, qR2, dl_top)
826
+
827
+ # Seismic force
828
+ kw = k_seismic * soil_weight
829
+
830
+ # === BEGIN : "Tension crack water force" ===
831
+
832
+ # By default, zero out t and its line‐of‐action:
833
+ t_force = 0.0
834
+ y_t_loc = 0.0
835
+
836
+ # Only nonzero for the appropriate end‐slice:
837
+ if tcrack_water is not None and tcrack_water > 0:
838
+ # Horizontal resultant of water in tension crack (triangular distribution):
839
+ # t = (1/2) * γ_w * (d_tc)^2
840
+ # Here, gamma_w is the unit weight of the crack‐water (y_w),
841
+ # and tcrack_water is the depth of water in the crack (d_tc).
842
+ t_force = 0.5 * gamma_w * (tcrack_water ** 2)
843
+
844
+ if right_facing:
845
+ # Right‐facing slope → water pushes on left side of the first slice (i == 0)
846
+ if i == 0:
847
+ # line of action is d_tc/3 above the bottom left corner y_lb
848
+ t_force = - t_force # negative because it acts to the right on free body diagram
849
+ y_t_loc = y_lb + (tcrack_water / 3.0)
850
+ else:
851
+ # other slices = no tension‐crack force
852
+ t_force = 0.0
853
+ y_t_loc = 0.0
854
+
855
+ else:
856
+ # Left‐facing slope → water pushes on right side of the last slice (i == n-1)
857
+ if i == (len(all_xs) - 2): # last slice index = (number_of_slices − 1)
858
+ # line of action is d_tc/3 above the bottom right corner y_rb
859
+ y_t_loc = y_rb + (tcrack_water / 3.0)
860
+ else:
861
+ t_force = 0.0
862
+ y_t_loc = y_rb
863
+ # === END: "Tension crack water force" ===
864
+
865
+ # === BEGIN : "Reinforcement lines" ===
866
+
867
+ # 1) Build this slice's base as a LineString from (x_l, y_lb) to (x_r, y_rb):
868
+ slice_base = LineString([(x_l, y_lb), (x_r, y_rb)])
869
+
870
+ # 2) For each reinforcement line, check a single‐point intersection:
871
+ p_sum = 0.0
872
+ for rl in reinf_lines_data:
873
+ intersec = slice_base.intersection(rl["geom"])
874
+ if intersec.is_empty:
875
+ continue
876
+
877
+ # Since we guarantee only one intersection point, it must be a Point:
878
+ if isinstance(intersec, Point):
879
+ xi = intersec.x
880
+ # interpolated T at xi
881
+ t_i = np.interp(xi, rl["xs"], rl["ts"], left=0.0, right=0.0)
882
+ p_sum += t_i
883
+ else:
884
+ # (In the extremely unlikely case that intersection is not a Point,
885
+ # skip it. Our assumption is only one Point per slice-base.)
886
+ continue
887
+
888
+ # Now p_sum is the TOTAL T‐pull acting at this slice's base.
889
+ # === END: "Tension crack water force" ===
890
+
891
+ # Process piezometric line and pore pressures using pre-computed coordinates
892
+ piezo_y = piezo_y_all[i]
893
+ piezo_y2 = piezo_y2_all[i]
894
+
895
+ hw = 0
896
+ hw2 = 0
897
+ u = 0
898
+ u2 = 0
899
+ # Determine pore pressure method from material property
900
+ mat_u = materials[base_material_idx]['u'] if base_material_idx is not None else 'none'
901
+ if mat_u == 'none':
902
+ u = 0
903
+ u2 = 0
904
+ elif mat_u == 'piezo':
905
+ if not np.isnan(piezo_y) and piezo_y > y_cb:
906
+ hw = piezo_y - y_cb
907
+ if not np.isnan(piezo_y2) and piezo_y2 > y_cb:
908
+ hw2 = piezo_y2 - y_cb
909
+ u = hw * gamma_w if not np.isnan(piezo_y) else 0
910
+ u2 = hw2 * gamma_w if not np.isnan(piezo_y2) else 0
911
+ elif mat_u == 'seep':
912
+ # Seepage-based pore pressure calculation using mesh interpolation
913
+ if 'seep_mesh' in data and 'seep_u' in data:
914
+ seep_mesh = data['seep_mesh']
915
+ seep_u = data['seep_u']
916
+
917
+ # Interpolate pore pressure at the slice center base point
918
+ point = (x_c, y_cb)
919
+ u = interpolate_at_point(
920
+ seep_mesh['nodes'],
921
+ seep_mesh['elements'],
922
+ seep_mesh['element_types'],
923
+ seep_u,
924
+ point
925
+ )
926
+ else:
927
+ u = 0
928
+
929
+ # Check for second seepage solution (rapid drawdown)
930
+ if 'seep_u2' in data:
931
+ seep_mesh = data['seep_mesh']
932
+ seep_u2 = data['seep_u2']
933
+
934
+ # Interpolate pore pressure at the slice center base point
935
+ point = (x_c, y_cb)
936
+ u2 = interpolate_at_point(
937
+ seep_mesh['nodes'],
938
+ seep_mesh['elements'],
939
+ seep_mesh['element_types'],
940
+ seep_u2,
941
+ point
942
+ )
943
+ else:
944
+ u2 = 0
945
+ else:
946
+ u = 0
947
+ u2 = 0
948
+
949
+ # Calculate alpha (slope angle of the failure surface) more efficiently
950
+ delta = 0.01
951
+ if circular:
952
+ # For circular failure surface, use parametric equation for derivative
953
+ # The slope at any point on the circle is: dy/dx = (x - Xo) / sqrt(R^2 - (x - Xo)^2)
954
+ dx_circle = x_c - Xo
955
+ if abs(dx_circle) < R: # Check if point is on the circle
956
+ alpha = degrees(atan(dx_circle / sqrt(R**2 - dx_circle**2)))
957
+ else:
958
+ # Fallback to numerical method
959
+ y1 = get_circular_y_coordinates([x_c - delta], Xo, Yo, R)[0]
960
+ y2 = get_circular_y_coordinates([x_c + delta], Xo, Yo, R)[0]
961
+ alpha = degrees(atan2(y2 - y1, 2 * delta))
962
+ else:
963
+ # For non-circular failure surface, use geometric intersection
964
+ failure_line = clipped_surface
965
+ y1 = get_y_from_intersection(
966
+ failure_line.intersection(LineString([(x_c - delta, -1e6), (x_c - delta, 1e6)])))
967
+ y2 = get_y_from_intersection(
968
+ failure_line.intersection(LineString([(x_c + delta, -1e6), (x_c + delta, 1e6)])))
969
+ if y1 is not None and y2 is not None:
970
+ alpha = degrees(atan2(y2 - y1, 2 * delta))
971
+ else:
972
+ alpha = 0
973
+
974
+ if right_facing:
975
+ alpha = -alpha
976
+ dl = dx / cos(radians(alpha))
977
+
978
+ if base_material_idx is None:
979
+ phi = 0
980
+ c = 0
981
+ c1 = 0 # not used in rapid drawdown, but must be defined
982
+ phi1 = 0 # not used in rapid drawdown, but must be defined
983
+ d = 0 # not used in rapid drawdown, but must be defined
984
+ psi = 0 # not used in rapid drawdown, but must be defined
985
+ else:
986
+ if materials[base_material_idx]['option'] == 'mc':
987
+ c = materials[base_material_idx]['c']
988
+ phi = materials[base_material_idx]['phi']
989
+ c1 = c # make a copy for use in rapid drawdown
990
+ phi1 = phi # make a copy for use in rapid drawdown
991
+ d = materials[base_material_idx]['d']
992
+ psi = materials[base_material_idx]['psi']
993
+ else:
994
+ c = (materials[base_material_idx]['r_elev'] - y_cb) * materials[base_material_idx]['cp']
995
+ phi = 0
996
+ c1 = 0 # not used in rapid drawdown, but must be defined
997
+ phi1 = 0 # not used in rapid drawdown, but must be defined
998
+ d = 0 # not used in rapid drawdown, but must be defined
999
+ psi = 0 # not used in rapid drawdown, but must be defined
1000
+
1001
+ # Prepare slice data with conditional circle parameters
1002
+ slice_data = {
1003
+ 'slice #': i + 1, # Slice numbering starts at 1
1004
+ 'x_l': x_l, # left x-coordinate of the slice
1005
+ 'y_lb': y_lb, # left y-coordinate of the slice base
1006
+ 'y_lt': y_lt, # left y-coordinate of the slice top
1007
+ 'x_r': x_r, # right x-coordinate of the slice
1008
+ 'y_rb': y_rb, # right y-coordinate of the slice base
1009
+ 'y_rt': y_rt, # right y-coordinate of the slice top
1010
+ 'x_c': x_c, # center x-coordinate of the slice
1011
+ 'y_cb': y_cb, # center y-coordinate of the slice base
1012
+ 'y_ct': y_ct, # center y-coordinate of the slice top
1013
+ 'y_cg': y_cg, # center of gravity y-coordinate of the slice
1014
+ 'dx': dx, # width of the slice
1015
+ 'alpha': alpha, # slope angle of the bottom of the slice in degrees
1016
+ 'dl': dl, # length of the slice along the failure surface
1017
+ **{f'h{j+1}': h for j, h in enumerate(heights)}, # heights of each layer in the slice
1018
+ 'w': soil_weight, # weight of the slice
1019
+ 'qL': qL, # distributed load intensity at left edge
1020
+ 'qR': qR, # distributed load intensity at right edge
1021
+ 'dload': dload, # distributed load resultant (area of trapezoid)
1022
+ 'd_x': d_x, # dist load resultant x-coordinate (point d)
1023
+ 'd_y': d_y, # dist load resultant y-coordinate (point d)
1024
+ 'qL2': qL2, # second distributed load intensity at left edge
1025
+ 'qR2': qR2, # second distributed load intensity at right edge
1026
+ 'dload2': dload2, # second distributed load resultant (area of trapezoid)
1027
+ 'd_x2': d_x2, # second dist load resultant x-coordinate (point d)
1028
+ 'd_y2': d_y2, # second dist load resultant y-coordinate (point d)
1029
+ 'beta': beta, # slope angle of the top edge in degrees
1030
+ 'kw': kw, # seismic force
1031
+ 't': t_force, # tension crack water force
1032
+ 'y_t': y_t_loc, # y-coordinate of the tension crack water force line of action
1033
+ 'p': p_sum, # sum of reinforcement line T values that intersect base of slice.
1034
+ 'n_eff': 0, # Placeholder for effective normal force
1035
+ 'z': 0, # Placeholder for interslice side forces
1036
+ 'theta': 0, # Placeholder for interslice angles
1037
+ 'piezo_y': piezo_y, # y-coordinate of the piezometric surface at x_c
1038
+ 'piezo_y2': piezo_y2, # y-coordinate of the piezometric surface at x_c for second piezometric line (rapid drawdown)
1039
+ 'hw': hw, # height of water at x_c
1040
+ 'u': u, # pore pressure at x_c
1041
+ 'hw2': hw2, # height of water at x_c for second piezometric line (rapid drawdown)
1042
+ 'u2': u2, # pore pressure at x_c for second piezometric line (rapid drawdown)
1043
+ 'mat': base_material_idx + 1 if base_material_idx is not None else None, # index of the base material (1-indexed)
1044
+ 'c': c, # cohesion of the base material
1045
+ 'phi': phi, # friction angle of the base material in degrees
1046
+ 'c1': c1, # cohesion of the base material for rapid drawdown
1047
+ 'phi1': phi1, # friction angle of the base material for rapid drawdown
1048
+ 'd': d, # d cohesion of the base material for rapid drawdown
1049
+ 'psi': psi, # psi friction angle of the base material for rapid drawdown
1050
+ }
1051
+
1052
+ # Add circle parameters only for circular failure surfaces
1053
+ if circular:
1054
+ slice_data.update({
1055
+ 'r': R, # radius of the circular failure surface
1056
+ 'xo': Xo, # x-coordinate of the center of the circular failure surface
1057
+ 'yo': Yo, # y-coordinate of the center of the circular failure surface
1058
+ })
1059
+ else:
1060
+ slice_data.update({
1061
+ 'r': None, # not applicable for non-circular failure surface
1062
+ 'xo': None, # not applicable for non-circular failure surface
1063
+ 'yo': None, # not applicable for non-circular failure surface
1064
+ })
1065
+ slices.append(slice_data)
1066
+
1067
+ df = pd.DataFrame(slices)
1068
+
1069
+ # Slice data were built by iterating from left to right. Flip the order slice data for right-facing slopes.
1070
+ # Slice 1 should be at the bottom and slice n at the top. This makes the slice data consistent with the
1071
+ # sign convention for alpha and the free-body diagram used to calculate forces.
1072
+ # if right_facing:
1073
+ # df = df.iloc[::-1].reset_index(drop=True)
1074
+
1075
+ return True, (df, clipped_surface)