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/solve.py ADDED
@@ -0,0 +1,1259 @@
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
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+ from scipy.optimize import minimize_scalar, root_scalar, newton
20
+ from shapely.geometry import LineString, Point
21
+ from tabulate import tabulate
22
+
23
+ from .advanced import rapid_drawdown
24
+
25
+ def solve_selected(method_name, slice_df, rapid=False):
26
+ """
27
+ Executes a specified limit equilibrium solution method and displays results.
28
+
29
+ Parameters
30
+ ----------
31
+ method_name : str
32
+ Name of the solution method function to call. Must be one of:
33
+ 'oms', 'bishop', 'janbu', 'spencer', 'corps_engineers', 'lowe_karafiath'
34
+ slice_df : pandas.DataFrame
35
+ Slice dataframe containing all required columns for the specified method
36
+ (see individual method documentation for column requirements)
37
+ rapid : bool, optional
38
+ If True, performs rapid drawdown analysis using the specified method.
39
+ Default is False.
40
+
41
+ Returns
42
+ -------
43
+ dict or str
44
+ If successful: dictionary containing method results (includes 'FS' and method-specific parameters)
45
+ If failed: error message string
46
+
47
+ Notes
48
+ -----
49
+ This function automatically prints the factor of safety and method-specific
50
+ parameters to the console. For methods with additional parameters:
51
+ - Spencer: displays theta (interslice force angle)
52
+ - Janbu: displays fo (correction factor)
53
+ - Corps of Engineers: displays theta
54
+ """
55
+
56
+ func = globals()[method_name]
57
+
58
+ if rapid:
59
+ success, result = rapid_drawdown(slice_df, method_name)
60
+ else:
61
+ success, result = func(slice_df)
62
+ if not success:
63
+ print(f'Error: {result}')
64
+ return result
65
+
66
+ if func == oms:
67
+ print(f'OMS: FS={result["FS"]:.3f}')
68
+ elif func == bishop:
69
+ print(f'Bishop: FS={result["FS"]:.3f}')
70
+ elif func == spencer:
71
+ print(f'Spencer: FS={result["FS"]:.3f}, theta={result["theta"]:.2f}')
72
+ elif func == janbu:
73
+ print(f'Janbu Corrected FS={result["FS"]:.3f}, fo={result["fo"]:.2f}')
74
+ elif func == corps_engineers:
75
+ print(f'Corps Engineers: FS={result["FS"]:.3f}, theta={result["theta"]:.2f}')
76
+ elif func == lowe_karafiath:
77
+ print(f'Lowe & Karafiath: FS={result["FS"]:.3f}')
78
+ return result
79
+
80
+ def solve_all(slice_df):
81
+ """
82
+ Executes all available limit equilibrium solution methods sequentially.
83
+
84
+ Runs six different limit equilibrium methods on the provided slice dataframe
85
+ and displays the factor of safety for each method. This is useful for comparing
86
+ results across multiple solution approaches.
87
+
88
+ Parameters
89
+ ----------
90
+ slice_df : pandas.DataFrame
91
+ Slice dataframe containing all required columns for all methods.
92
+ Must include: 'alpha', 'phi', 'c', 'w', 'u', 'dl', 'dload', 'd_x', 'd_y',
93
+ 'beta', 'kw', 't', 'y_t', 'p', 'x_c', 'y_cg', and additional columns
94
+ required for specific methods (e.g., 'r', 'xo', 'yo' for circular methods).
95
+
96
+ Returns
97
+ -------
98
+ None
99
+ Results are printed to console for each method.
100
+
101
+ Notes
102
+ -----
103
+ Methods executed in order:
104
+ 1. Ordinary Method of Slices (OMS)
105
+ 2. Bishop's Simplified Method
106
+ 3. Janbu's Simplified Method
107
+ 4. Corps of Engineers Method
108
+ 5. Lowe & Karafiath Method
109
+ 6. Spencer's Method
110
+
111
+ If any method fails, an error message is displayed but execution continues
112
+ with the remaining methods.
113
+ """
114
+ solve_selected('oms', slice_df)
115
+ solve_selected('bishop', slice_df)
116
+ solve_selected('janbu', slice_df)
117
+ solve_selected('corps_engineers', slice_df)
118
+ solve_selected('lowe_karafiath', slice_df)
119
+ solve_selected('spencer', slice_df)
120
+
121
+ def oms(slice_df, debug=False):
122
+ """
123
+ Computes FS by direct application of Equation 9 (Ordinary Method of Slices).
124
+
125
+ Inputs
126
+ ------
127
+ slice_df : pandas.DataFrame
128
+ Must contain exactly these columns (length = n slices):
129
+ 'alpha' (deg) = base inclination αᵢ
130
+ 'phi' (deg) = friction angle φᵢ
131
+ 'c' = cohesion cᵢ
132
+ 'w' = slice weight Wᵢ
133
+ 'u' = pore pressure force/unit‐length on base, uᵢ
134
+ 'dl' = base length Δℓᵢ
135
+ 'd' = resultant distributed load Dᵢ
136
+ 'd_x','d_y' = centroid (x,y) at which Dᵢ acts
137
+ 'beta' (deg) = top slope βᵢ
138
+ 'kw' = seismic horizontal kWᵢ
139
+ 't' = tension‐crack horizontal Tᵢ (zero except one slice)
140
+ 'y_t' = y‐loc of Tᵢ's line of action (zero except that one slice)
141
+ 'p' = reinforcement uplift pᵢ (zero if none)
142
+ 'x_c','y_cg' = slice‐centroid (x,y) for seismic moment arm
143
+ 'r' = radius of circular failure surface
144
+ 'xo','yo' = x,y coordinates of circle center
145
+
146
+ Returns
147
+ -------
148
+ (bool, dict_or_str)
149
+ • If success: (True, {'method':'oms', 'FS': <computed value>})
150
+ • If denominator → 0 or other fatal error: (False, "<error message>")
151
+
152
+
153
+ """
154
+ if 'r' not in slice_df.columns:
155
+ return False, "Circle is required for OMS method."
156
+
157
+ # 1) Unpack circle‐center and radius as single values
158
+ Xo = slice_df['xo'].iloc[0] # Xoᵢ (x-coordinate of circle center)
159
+ Yo = slice_df['yo'].iloc[0] # Yoᵢ (y-coordinate of circle center)
160
+ R = slice_df['r'].iloc[0] # Rᵢ (radius of circular failure surface)
161
+
162
+ # 2) Pull arrays directly from slice_df
163
+ alpha_deg = slice_df['alpha'].values # αᵢ in degrees
164
+ phi_deg = slice_df['phi'].values # φᵢ in degrees
165
+ c = slice_df['c'].values # cᵢ
166
+ W = slice_df['w'].values # Wᵢ
167
+ u = slice_df['u'].values # uᵢ (pore‐force per unit length)
168
+ dl = slice_df['dl'].values # Δℓᵢ
169
+ D = slice_df['dload'].values # Dᵢ
170
+ d_x = slice_df['d_x'].values # d_{x,i}
171
+ d_y = slice_df['d_y'].values # d_{y,i}
172
+ beta_deg = slice_df['beta'].values # βᵢ in degrees
173
+ kw = slice_df['kw'].values # kWᵢ
174
+ T = slice_df['t'].values # Tᵢ (zero except one slice)
175
+ y_t = slice_df['y_t'].values # y_{t,i} (zero except one slice)
176
+ P = slice_df['p'].values # pᵢ
177
+ x_c = slice_df['x_c'].values # x_{c,i}
178
+ y_cg = slice_df['y_cg'].values # y_{cg,i} coordinate of slice centroid
179
+
180
+ # 3) Convert angles to radians
181
+ alpha = np.radians(alpha_deg) # αᵢ [rad]
182
+ phi = np.radians(phi_deg) # φᵢ [rad]
183
+ beta = np.radians(beta_deg) # βᵢ [rad]
184
+
185
+ # 4) Precompute sines/cosines
186
+ sin_alpha = np.sin(alpha) # sin(αᵢ)
187
+ cos_alpha = np.cos(alpha) # cos(αᵢ)
188
+ sin_ab = np.sin(alpha - beta) # sin(αᵢ−βᵢ)
189
+ cos_ab = np.cos(alpha - beta) # cos(αᵢ−βᵢ)
190
+ tan_phi = np.tan(phi) # tan(φᵢ)
191
+
192
+ # ————————————————————————————————————————————————————————
193
+ # 5) Build the NUMERATOR = Σᵢ [ cᵢ·Δℓᵢ
194
+ # + (Wᵢ·cosαᵢ + Dᵢ·cos(αᵢ−βᵢ) − kWᵢ·sinαᵢ − Tᵢ·sinαᵢ − uᵢ·Δℓᵢ )·tanφᵢ
195
+ # + pᵢ ] + Σ Dᵢ·sinβᵢ·(Yo - d_{y,i})
196
+ #
197
+
198
+
199
+ # N′ᵢ = Wᵢ·cosαᵢ + Dᵢ·cos(αᵢ−βᵢ) − kWᵢ·sinαᵢ − Tᵢ·sinαᵢ − uᵢ·Δℓᵢ
200
+ N_eff = (
201
+ W * cos_alpha
202
+ + D * cos_ab
203
+ - kw * sin_alpha
204
+ - T * sin_alpha
205
+ - (u * dl)
206
+ )
207
+
208
+ # Σ Dᵢ·sinβᵢ·(Yo - d_{y,i})
209
+ a_dy = Yo - d_y
210
+ sum_Dy = np.sum(D * np.sin(beta) * a_dy)
211
+
212
+ numerator = np.sum(c * dl + N_eff * tan_phi + P)+ (1.0 / R) * sum_Dy
213
+
214
+ # ————————————————————————————————————————————————————————
215
+ # 6) Build each piece of the DENOMINATOR exactly as Eqn 9:
216
+
217
+ # (A) = Σ [ Wᵢ · sinαᵢ ]
218
+ sum_W = np.sum(W * sin_alpha)
219
+
220
+ # (B) = Σ Dᵢ·cosβᵢ·(Xo - d_{x,i})
221
+ a_dx = d_x - Xo
222
+ sum_Dx = np.sum(D * np.cos(beta) * a_dx)
223
+
224
+ # (C) = Σ [ kWᵢ * (Yo - y_{cg,i}) ]
225
+ a_s = Yo - y_cg
226
+ sum_kw = np.sum(kw * a_s)
227
+
228
+ # (D) = Σ [ Tᵢ * (Yo - y_{t,i}) ]
229
+ a_t = Yo - y_t
230
+ sum_T = np.sum(T * a_t)
231
+
232
+ # Put them together with their 1/R factors:
233
+ denominator = sum_W + (1.0 / R) * (sum_Dx + sum_kw + sum_T)
234
+
235
+ # 7) Finally compute FS = (numerator)/(denominator)
236
+ FS = numerator / denominator
237
+
238
+ # 8) Store effective normal forces in the DataFrame
239
+ slice_df['n_eff'] = N_eff
240
+
241
+ if debug==True:
242
+ print(f'numerator = {numerator:.4f}')
243
+ print(f'denominator = {denominator:.4f}')
244
+ print(f'Sum_W = {sum_W:.4f}')
245
+ print(f'Sum_Dx = {sum_Dx:.4f}')
246
+ print(f'Sum_Dy = {sum_Dy:.4f}')
247
+ print(f'Sum_kw = {sum_kw:.4f}')
248
+ print(f'Sum_T = {sum_T:.4f}')
249
+ print('N_eff =', np.array2string(N_eff, precision=4, separator=', '))
250
+
251
+ # 9) Return success and the FS
252
+ return True, {'method': 'oms', 'FS': FS}
253
+
254
+ def bishop(slice_df, debug=False, tol=1e-6, max_iter=100):
255
+ """
256
+ Computes FS using the complete Bishop's Simplified Method (Equation 10) and computes N_eff (Equation 8).
257
+ Requires circular slip surface and full input data structure consistent with OMS.
258
+
259
+ Parameters:
260
+ slice_df : pandas.DataFrame with required columns (see OMS spec)
261
+ debug : bool, if True prints diagnostic info
262
+ tol : float, convergence tolerance
263
+ max_iter : int, maximum iteration steps
264
+
265
+ Returns:
266
+ (bool, dict | str): (True, {'method': 'bishop', 'FS': value}) or (False, error message)
267
+ """
268
+
269
+ if 'r' not in slice_df.columns:
270
+ return False, "Circle is required for Bishop method."
271
+
272
+ # 1) Unpack circle‐center and radius as single values
273
+ Xo = slice_df['xo'].iloc[0] # Xoᵢ (x-coordinate of circle center)
274
+ Yo = slice_df['yo'].iloc[0] # Yoᵢ (y-coordinate of circle center)
275
+ R = slice_df['r'].iloc[0] # Rᵢ (radius of circular failure surface)
276
+
277
+ # Load input arrays
278
+ alpha = np.radians(slice_df['alpha'].values)
279
+ phi = np.radians(slice_df['phi'].values)
280
+ c = slice_df['c'].values
281
+ W = slice_df['w'].values
282
+ u = slice_df['u'].values
283
+ dl = slice_df['dl'].values
284
+ D = slice_df['dload'].values
285
+ d_x = slice_df['d_x'].values
286
+ d_y = slice_df['d_y'].values
287
+ beta = np.radians(slice_df['beta'].values)
288
+ kw = slice_df['kw'].values
289
+ T = slice_df['t'].values
290
+ y_t = slice_df['y_t'].values
291
+ P = slice_df['p'].values
292
+ x_c = slice_df['x_c'].values
293
+ y_cg = slice_df['y_cg'].values
294
+
295
+ # Trigonometric terms
296
+ sin_alpha = np.sin(alpha)
297
+ cos_alpha = np.cos(alpha)
298
+ tan_phi = np.tan(phi)
299
+ sin_beta = np.sin(beta)
300
+ cos_beta = np.cos(beta)
301
+
302
+ # Moment arms
303
+ a_dx = d_x - Xo
304
+ a_dy = Yo - d_y
305
+ a_s = Yo - y_cg
306
+ a_t = Yo - y_t
307
+
308
+ # Denominator (moment equilibrium)
309
+ sum_W = np.sum(W * sin_alpha)
310
+ sum_Dx = np.sum(D * cos_beta * a_dx)
311
+ sum_Dy = np.sum(D * sin_beta * a_dy)
312
+ sum_kw = np.sum(kw * a_s)
313
+ sum_T = np.sum(T * a_t)
314
+ denominator = sum_W + (1.0 / R) * (sum_Dx + sum_kw + sum_T)
315
+
316
+ # Iterative solution
317
+ F = 1.0
318
+ for _ in range(max_iter):
319
+ # Compute N_eff from Equation (8)
320
+ num_N = (
321
+ W + D * cos_beta - P * sin_alpha
322
+ - u * dl * cos_alpha
323
+ - (c * dl * sin_alpha) / F
324
+ )
325
+ denom_N = cos_alpha + (sin_alpha * tan_phi) / F
326
+ N_eff = num_N / denom_N
327
+
328
+ # Numerator for FS from Equation (10)
329
+ shear = (
330
+ c * dl * cos_alpha
331
+ + (W + D * cos_beta - P * sin_alpha - u * dl * cos_alpha) * tan_phi
332
+ + P
333
+ )
334
+ numerator = np.sum(shear / denom_N) + (1.0 / R) * sum_Dy
335
+ F_new = numerator / denominator
336
+
337
+ if abs(F_new - F) < tol:
338
+ slice_df['n_eff'] = N_eff
339
+ if debug:
340
+ print(f"FS = {F_new:.6f}")
341
+ print(f"Numerator = {numerator:.6f}")
342
+ print(f"Denominator = {denominator:.6f}")
343
+ print("N_eff =", np.array2string(N_eff, precision=4, separator=', '))
344
+ return True, {'method': 'bishop', 'FS': F_new}
345
+
346
+ F = F_new
347
+
348
+ return False, "Bishop method did not converge within the maximum number of iterations."
349
+
350
+ def janbu(slice_df, debug=False):
351
+ """
352
+ Computes FS using Janbu's Simplified Method with correction factor (Equation 7).
353
+
354
+ Implements the complete formulation including distributed loads, seismic forces,
355
+ reinforcement, and tension crack water forces. Applies Janbu correction factor
356
+ based on d/L ratio and soil type.
357
+
358
+ Parameters:
359
+ slice_df : pandas.DataFrame with required columns (see OMS spec)
360
+ debug : bool, if True prints diagnostic info
361
+
362
+ Returns:
363
+ (bool, dict | str): (True, {'method': 'janbu_simplified', 'FS': value, 'fo': correction_factor})
364
+ or (False, error message)
365
+ """
366
+
367
+ # Load input arrays
368
+ alpha = np.radians(slice_df['alpha'].values)
369
+ phi = np.radians(slice_df['phi'].values)
370
+ c = slice_df['c'].values
371
+ W = slice_df['w'].values
372
+ u = slice_df['u'].values
373
+ dl = slice_df['dl'].values
374
+ D = slice_df['dload'].values
375
+ beta = np.radians(slice_df['beta'].values)
376
+ kw = slice_df['kw'].values
377
+ T = slice_df['t'].values
378
+ P = slice_df['p'].values
379
+
380
+ # Trigonometric terms
381
+ sin_alpha = np.sin(alpha)
382
+ cos_alpha = np.cos(alpha)
383
+ tan_phi = np.tan(phi)
384
+ sin_beta_alpha = np.sin(beta - alpha)
385
+ cos_beta_alpha = np.cos(beta - alpha)
386
+
387
+ # Effective normal forces (Equation 10)
388
+ N_eff = W * cos_alpha - kw * sin_alpha + D * cos_beta_alpha - T * sin_alpha - u * dl
389
+
390
+ # Numerator: resisting forces (shear resistance)
391
+ numerator = np.sum(c * dl + N_eff * tan_phi + P)
392
+
393
+ # Denominator: driving forces parallel to base (Equation 6)
394
+ denominator = np.sum(W * sin_alpha + kw * cos_alpha - D * sin_beta_alpha + T * cos_alpha)
395
+
396
+ # Base factor of safety (Equation 7)
397
+ if abs(denominator) < 1e-12:
398
+ return False, "Division by zero in Janbu method: driving forces sum to zero"
399
+
400
+ FS_base = numerator / denominator
401
+
402
+ # === Compute Janbu correction factor ===
403
+
404
+ # Get failure surface endpoints
405
+ x_l = slice_df['x_l'].iloc[0] # leftmost x
406
+ y_lt = slice_df['y_lt'].iloc[0] # leftmost top y
407
+ x_r = slice_df['x_r'].iloc[-1] # rightmost x
408
+ y_rt = slice_df['y_rt'].iloc[-1] # rightmost top y
409
+
410
+ # Length of failure surface (straight line approximation)
411
+ L = np.hypot(x_r - x_l, y_rt - y_lt)
412
+
413
+ # Calculate perpendicular distance from each slice center to failure surface line
414
+ x0 = slice_df['x_c'].values
415
+ y0 = slice_df['y_cb'].values
416
+
417
+ # Distance from point to line formula: |ax + by + c| / sqrt(a² + b²)
418
+ # Line equation: (y_rt - y_lt)x - (x_r - x_l)y + (x_r * y_lt - y_rt * x_l) = 0
419
+ numerator_dist = np.abs((y_rt - y_lt) * x0 - (x_r - x_l) * y0 + x_r * y_lt - y_rt * x_l)
420
+ dists = numerator_dist / L
421
+ d = np.max(dists) # maximum perpendicular distance
422
+
423
+ dL_ratio = d / L
424
+
425
+ # Determine b1 factor based on soil type
426
+ phi_sum = slice_df['phi'].sum()
427
+ c_sum = slice_df['c'].sum()
428
+
429
+ if phi_sum == 0: # c-only soil (undrained, φ = 0)
430
+ b1 = 0.67
431
+ elif c_sum == 0: # φ-only soil (no cohesion)
432
+ b1 = 0.31
433
+ else: # c-φ soil
434
+ b1 = 0.50
435
+
436
+ # Correction factor
437
+ fo = 1 + b1 * (dL_ratio - 1.4 * dL_ratio ** 2)
438
+
439
+ # Final corrected factor of safety
440
+ FS = FS_base * fo
441
+
442
+ # Store effective normal forces in DataFrame
443
+ slice_df['n_eff'] = N_eff
444
+
445
+ if debug:
446
+ print(f"FS_base = {FS_base:.6f}")
447
+ print(f"d/L ratio = {dL_ratio:.4f}")
448
+ print(f"b1 factor = {b1:.2f}")
449
+ print(f"fo correction = {fo:.4f}")
450
+ print(f"FS_corrected = {FS:.6f}")
451
+ print(f"Numerator = {numerator:.6f}")
452
+ print(f"Denominator = {denominator:.6f}")
453
+ print("N_eff =", np.array2string(N_eff, precision=4, separator=', '))
454
+
455
+ return True, {
456
+ 'method': 'janbu',
457
+ 'FS': FS,
458
+ 'fo': fo
459
+ }
460
+
461
+
462
+ def force_equilibrium(slice_df, theta_list, fs_guess=1.5, tol=1e-6, max_iter=50, debug=False):
463
+ """
464
+ Limit‐equilibrium by force equilibrium in X & Y with variable interslice angles.
465
+
466
+ Parameters:
467
+ slice_df (pd.DataFrame): must contain columns
468
+ 'alpha' (slice base inclination, degrees),
469
+ 'phi' (slice friction angle, degrees),
470
+ 'c' (cohesion),
471
+ 'dl' (slice base length),
472
+ 'w' (slice weight),
473
+ 'u' (pore force per unit length),
474
+ 'd' (distributed load),
475
+ 'beta' (distributed load inclination, degrees),
476
+ 'kw' (seismic force),
477
+ 't' (tension crack water force),
478
+ 'p' (reinforcement force)
479
+ theta_list (array-like): slice‐boundary force inclinations (degrees),
480
+ length must be n+1 if there are n slices
481
+ fs_guess (float): initial guess for factor of safety
482
+ tol (float): convergence tolerance on residual
483
+ max_iter (int): maximum number of Newton (secant) iterations
484
+ debug (bool): print residuals during iteration
485
+
486
+ Returns:
487
+ (bool, dict or str):
488
+ - If converged: (True, {'method':'force_equilibrium','FS':<value>})
489
+ - If failed: (False, "error message")
490
+ """
491
+ import numpy as np
492
+
493
+ n = len(slice_df)
494
+ if len(theta_list) != n+1:
495
+ return False, f"theta_list length ({len(theta_list)}) must be n+1 ({n+1})"
496
+
497
+ # extract and convert to radians
498
+ alpha = np.radians(slice_df['alpha'].values)
499
+ phi = np.radians(slice_df['phi'].values)
500
+ c = slice_df['c'].values
501
+ w = slice_df['w'].values
502
+ u = slice_df['u'].values
503
+ dl = slice_df['dl'].values
504
+ D = slice_df['dload'].values
505
+ beta = np.radians(slice_df['beta'].values)
506
+ kw = slice_df['kw'].values
507
+ T = slice_df['t'].values
508
+ P = slice_df['p'].values
509
+ theta = np.radians(np.asarray(theta_list))
510
+ N = np.zeros(n) # normal forces on slice bases
511
+ Z = np.zeros(n+1) # interslice forces, Z[0] = 0 by definition (no force entering leftmost slice)
512
+
513
+ def residual(FS):
514
+ """Return the right‐side interslice force Z[n] for a given FS."""
515
+ c_m = c / FS
516
+ tan_phi_m = np.tan(phi) / FS
517
+ Z[:] = 0.0 # reset Z for each call
518
+ for i in range(n):
519
+ ca, sa = np.cos(alpha[i]), np.sin(alpha[i])
520
+ cb, sb = np.cos(beta[i]), np.sin(beta[i])
521
+
522
+ # Matrix A coefficients from equations (6) and (7)
523
+ A = np.array([
524
+ [tan_phi_m[i]*ca - sa, -np.cos(theta[i+1])],
525
+ [tan_phi_m[i]*sa + ca, -np.sin(theta[i+1])]
526
+ ])
527
+
528
+ # Vector b from equations (6) and (7)
529
+ b0 = (
530
+ -c_m[i]*dl[i]*ca
531
+ - P[i]*ca
532
+ + u[i]*dl[i]*sa
533
+ - Z[i]*np.cos(theta[i])
534
+ - D[i]*sb
535
+ + kw[i]
536
+ + T[i]
537
+ )
538
+ b1 = (
539
+ -c_m[i]*dl[i]*sa
540
+ - P[i]*sa
541
+ - u[i]*dl[i]*ca
542
+ + w[i]
543
+ - Z[i]*np.sin(theta[i])
544
+ + D[i]*cb
545
+ )
546
+
547
+ N_i, Z_ip1 = np.linalg.solve(A, np.array([b0, b1]))
548
+ Z[i+1] = Z_ip1
549
+ N[i] = N_i # store normal force on slice base
550
+ return Z[n]
551
+
552
+ if debug:
553
+ r0 = residual(fs_guess)
554
+ print(f"FS_guess={fs_guess:.6f} → residual={r0:.4g}")
555
+
556
+ # use Newton‐secant (no derivative) with single initial guess
557
+ try:
558
+ FS_opt = newton(residual, fs_guess, tol=tol, maxiter=max_iter)
559
+ except Exception as e:
560
+ return False, f"force_equilibrium failed to converge: {e}"
561
+
562
+ slice_df['n_eff'] = N # store effective normal forces in slice_df
563
+ slice_df['z'] = Z[:-1] # store interslice forces in slice_df, adjust length to n slices
564
+
565
+ if debug:
566
+ r_opt = residual(FS_opt)
567
+ print(f" Converged FS = {FS_opt:.6f}, residual = {r_opt:.4g}")
568
+
569
+ return True, {'FS': FS_opt}
570
+
571
+ def corps_engineers(slice_df, debug=False):
572
+ """
573
+ Corps of Engineers style force equilibrium solver.
574
+
575
+ 1. Computes a single θ from the slope between
576
+ (x_l[0], y_lt[0]) and (x_r[-1], y_rt[-1]).
577
+ 2. Builds a constant θ array of length n+1.
578
+ 3. Calls force_equilibrium(slice_df, theta_array).
579
+
580
+ Parameters:
581
+ slice_df (pd.DataFrame): Must include at least ['x_l','y_lt','x_r','y_rt']
582
+ plus all the columns required by force_equilibrium:
583
+ ['alpha','phi','c','dl','w','u','dx'].
584
+
585
+ Returns:
586
+ Tuple(bool, dict or str): Whatever force_equilibrium returns.
587
+ """
588
+ # endpoints of the slip surface
589
+ x0, y0 = slice_df['x_l'].iat[0], slice_df['y_lt'].iat[0]
590
+ x1, y1 = slice_df['x_r'].iat[-1], slice_df['y_rt'].iat[-1]
591
+
592
+ # compute positive slope‐angle
593
+ dx = x1 - x0
594
+ dy = y1 - y0
595
+ if abs(dx) < 1e-12:
596
+ theta_deg = 90.0
597
+ else:
598
+ theta_deg = abs(np.degrees(np.arctan2(dy, dx)))
599
+
600
+ # one theta per slice boundary
601
+ n = len(slice_df)
602
+ theta_list = np.full(n+1, theta_deg)
603
+
604
+ slice_df['theta'] = theta_list[:-1] # store theta in slice_df. Adjust length to n slices.
605
+
606
+ # delegate to your force_equilibrium solver
607
+ success, results = force_equilibrium(slice_df, theta_list, debug=debug)
608
+ if not success:
609
+ return success, results
610
+ else:
611
+ results['method'] = 'corps_engineers' # append method
612
+ results['theta'] = theta_deg # append theta
613
+ return success, results
614
+
615
+ def lowe_karafiath(slice_df, debug=False):
616
+ """
617
+ Lowe-Karafiath limit equilibrium: variable interslice inclinations equal to
618
+ the average of the top‐and bottom‐surface slopes of the two adjacent slices
619
+ at each boundary.
620
+ """
621
+ n = len(slice_df)
622
+
623
+ # grab boundary coords
624
+ x_l = slice_df['x_l'].values
625
+ y_lt = slice_df['y_lt'].values
626
+ y_lb = slice_df['y_lb'].values
627
+ x_r = slice_df['x_r'].values
628
+ y_rt = slice_df['y_rt'].values
629
+ y_rb = slice_df['y_rb'].values
630
+
631
+ # determine facing
632
+ right_facing = (y_lt[0] > y_rt[-1])
633
+
634
+ # precompute each slice's top & bottom slopes
635
+ widths = (x_r - x_l)
636
+ slope_top = (y_rt - y_lt) / widths
637
+ slope_bottom = (y_rb - y_lb) / widths
638
+
639
+ # build θ_list for j=0..n
640
+ if debug:
641
+ print("boundary slopes (top/bottom) avg, θ_list:") # header for debug list
642
+
643
+ theta_list = np.zeros(n+1)
644
+ for j in range(n+1):
645
+ if j == 0:
646
+ st = slope_top[0]
647
+ sb = slope_bottom[0]
648
+ elif j == n:
649
+ st = slope_top[-1]
650
+ sb = slope_bottom[-1]
651
+ else:
652
+ st = 0.5*(slope_top[j-1] + slope_top[j])
653
+ sb = 0.5*(slope_bottom[j-1] + slope_bottom[j])
654
+
655
+ avg_slope = 0.5*(st + sb)
656
+ theta = np.degrees(np.arctan(avg_slope))
657
+
658
+ # sign convention
659
+ if right_facing:
660
+ theta_list[j] = -theta
661
+ else:
662
+ theta_list[j] = theta
663
+
664
+ if debug:
665
+ print(f" j={j:2d}: st={st:.3f}, sb={sb:.3f}, θ={theta:.3f}°")
666
+
667
+ slice_df['theta'] = theta_list[:-1] # store theta in slice_df. Adjust length to n slices.
668
+
669
+ # call your force_equilibrium solver
670
+ success, results = force_equilibrium(slice_df, theta_list, debug=debug)
671
+ if not success:
672
+ return success, results
673
+ else:
674
+ results['method'] = 'lowe_karafiath' # append method
675
+ return success, results
676
+
677
+ def spencer(slice_df, tol=1e-4, max_iter = 100, debug_level=0):
678
+ """
679
+ Spencer's Method using Steve G. Wright's formulation from the UTEXAS v2 user manual.
680
+
681
+
682
+ Parameters:
683
+ slice_df (pd.DataFrame): must contain columns
684
+ 'alpha' (slice base inclination, degrees),
685
+ 'phi' (slice friction angle, degrees),
686
+ 'c' (cohesion),
687
+ 'dl' (slice base length),
688
+ 'w' (slice weight),
689
+ 'u' (pore force per unit length),
690
+ 'd' (distributed load),
691
+ 'beta' (distributed load inclination, degrees),
692
+ 'kw' (seismic force),
693
+ 't' (tension crack water force),
694
+ 'p' (reinforcement force)
695
+
696
+ Returns:
697
+ float: FS where FS_force = FS_moment
698
+ float: beta (degrees)
699
+ bool: converged flag
700
+ """
701
+
702
+ alpha = np.radians(slice_df['alpha'].values) # slice base inclination, degrees
703
+ phi = np.radians(slice_df['phi'].values) # slice friction angle, degrees
704
+ c = slice_df['c'].values # cohesion
705
+ dx = slice_df['dx'].values # slice width
706
+ dl = slice_df['dl'].values # slice base length
707
+ W = slice_df['w'].values # slice weight
708
+ u = slice_df['u'].values # pore presssure
709
+ x_c = slice_df['x_c'].values # center of base x-coordinate
710
+ y_cb = slice_df['y_cb'].values # center of base y-coordinate
711
+ y_lb = slice_df['y_lb'].values # left side base y-coordinate
712
+ y_rb = slice_df['y_rb'].values # right side base y-coordinate
713
+ P = slice_df['dload'].values # distributed load resultant
714
+ beta = np.radians(slice_df['beta'].values) # distributed load inclination, degrees
715
+ kw = slice_df['kw'].values # seismic force
716
+ V = slice_df['t'].values # tension crack water force
717
+ y_v = slice_df['y_t'].values # tension crack water force y-coordinate
718
+ R = slice_df['p'].values # reinforcement force
719
+
720
+ # For now, we assume that reinforcement is flexible and therefore is parallel to the failure surface
721
+ # at the bottom of the slice. Therefore, the psi value used in the derivation is set to alpha,
722
+ # and the point of action is the center of the base of the slice.
723
+ psi = alpha # psi is the angle of the reinforcement force from the horizontal
724
+ y_r = y_cb # y_r is the y-coordinate of the point of action of the reinforcement
725
+ x_r = x_c # x_r is the x-coordinate of the point of action of the reinforcement
726
+
727
+ # use variable names to match the derivation.
728
+ x_p = slice_df['d_x'].values # distributed load x-coordinate
729
+ y_p = slice_df['d_y'].values # distributed load y-coordinate
730
+ y_k = slice_df['y_cg'].values # seismic force y-coordinate
731
+ x_b = x_c # center of base x-coordinate
732
+ y_b = y_cb # center of base y-coordinate
733
+
734
+
735
+ tan_p = np.tan(phi) # tan(phi)
736
+
737
+ y_ct = slice_df['y_ct'].values
738
+ right_facing = (y_ct[0] > y_ct[-1])
739
+ # If right facing, swap angles and strengths. For most methods, you can use the normal angle conventions
740
+ # and get the right answer. But for Spencer, due to the way that the moment equation is written,
741
+ # you need to swap the angles and strengths if the slope is right facing.
742
+ if right_facing:
743
+ alpha = -alpha
744
+ beta = -beta
745
+ psi = -psi
746
+ R = -R
747
+ c = -c
748
+ kw = -kw
749
+ tan_p = -tan_p
750
+
751
+ # pre-compute the trigonometric functions
752
+ cos_a = np.cos(alpha) # cos(alpha)
753
+ sin_a = np.sin(alpha) # sin(alpha)
754
+ #tan_p = np.tan(phi) # tan(phi) # moved above
755
+ cos_b = np.cos(beta) # cos(beta)
756
+ sin_b = np.sin(beta) # sin(beta)
757
+ sin_psi = np.sin(psi) # sin(psi)
758
+ cos_psi = np.cos(psi) # cos(psi)
759
+
760
+ Fh = - kw - V + P * sin_b + R * cos_psi # Equation (1)
761
+ Fv = - W - P * cos_b + R * sin_psi # Equation (2)
762
+ Mo = - P * sin_b * (y_p - y_b) - P * cos_b * (x_p - x_b) \
763
+ + kw * (y_k - y_b) + V * (y_v - y_b) - R * cos_psi * (y_r - y_b) + R * sin_psi * (x_r - x_b) # Equation (3)
764
+
765
+ # ========== BEGIN SOLUTION ==========
766
+
767
+ def compute_Q_and_yQ(F, theta_rad):
768
+ """Compute Q and y_Q for given F and theta values."""
769
+ # Equation (24): m_alpha
770
+ ma = 1 / (np.cos(alpha - theta_rad) + np.sin(alpha - theta_rad) * tan_p / F)
771
+
772
+ # Equation (23): Q
773
+ Q = (- Fv * sin_a - Fh * cos_a - (c / F) * dl + (Fv * cos_a - Fh * sin_a + u * dl) * tan_p / F) * ma
774
+
775
+ # Equation (26): y_Q
776
+ y_q = y_b + Mo / (Q * np.cos(theta_rad))
777
+
778
+ return Q, y_q
779
+
780
+ def compute_residuals(F, theta_rad):
781
+ """Compute residuals R1 and R2 for given F and theta values."""
782
+ Q, y_q = compute_Q_and_yQ(F, theta_rad)
783
+
784
+ # Equation (27): R1 = sum(Q)
785
+ R1 = np.sum(Q)
786
+
787
+ # Equation (28): R2 = sum(Q * (x_b * sin(theta) - y_Q * cos(theta)))
788
+ R2 = np.sum(Q * (x_b * np.sin(theta_rad) - y_q * np.cos(theta_rad)))
789
+
790
+ return R1, R2, Q, y_q
791
+
792
+
793
+ def compute_derivatives(F, theta_rad, Q, y_q):
794
+
795
+ """Compute all derivatives needed for Newton's method."""
796
+ # Precompute trigonometric terms
797
+ cos_alpha_theta = np.cos(alpha - theta_rad)
798
+ sin_alpha_theta = np.sin(alpha - theta_rad)
799
+ cos_theta = np.cos(theta_rad)
800
+ sin_theta = np.sin(theta_rad)
801
+
802
+ # Constants for Q expression (Equations 45-49)
803
+ C1 = -Fv * sin_a - Fh * cos_a
804
+ C2 = -c * dl + (Fv * cos_a - Fh * sin_a + u * dl) * tan_p
805
+ C3 = cos_alpha_theta
806
+ C4 = sin_alpha_theta * tan_p
807
+
808
+ # Denominator for Q
809
+ denom_Q = C3 + C4 / F
810
+
811
+ # First-order partial derivatives of Q (Equations 50-51)
812
+ dQ_dF = (-1 / denom_Q**2) * ((denom_Q * C2 / F**2) - (C1 + C2 / F) * C4 / F**2)
813
+
814
+ dC3_dtheta = sin_alpha_theta # Equation (55)
815
+ dC4_dtheta = -cos_alpha_theta * tan_p # Equation (56)
816
+ dQ_dtheta = (-1 / denom_Q**2) * (C1 + C2 / F) * (dC3_dtheta + dC4_dtheta / F)
817
+
818
+ # Partial derivatives of y_Q (Equations 59-60)
819
+ dyQ_dF = (-1 / (Q * cos_theta)**2) * Mo * dQ_dF * cos_theta
820
+ dyQ_dtheta = (-1 / (Q * cos_theta)**2) * Mo * (dQ_dtheta * cos_theta - Q * sin_theta)
821
+
822
+ # First-order partial derivatives of R1 (Equations 35-36)
823
+ dR1_dF = np.sum(dQ_dF)
824
+ dR1_dtheta = np.sum(dQ_dtheta)
825
+
826
+ # First-order partial derivatives of R2 (Equations 40-41)
827
+ dR2_dF = np.sum(dQ_dF * (x_b * sin_theta - y_q * cos_theta)) - np.sum(Q * dyQ_dF * cos_theta)
828
+ dR2_dtheta = np.sum(dQ_dtheta * (x_b * sin_theta - y_q * cos_theta)) + np.sum(Q * (x_b * cos_theta + y_q * sin_theta - dyQ_dtheta * cos_theta))
829
+
830
+ return dR1_dF, dR1_dtheta, dR2_dF, dR2_dtheta
831
+
832
+
833
+ # Initial guesses
834
+ F0 = 1.5
835
+ if right_facing:
836
+ theta0_rad = np.radians(-8.0)
837
+ else:
838
+ theta0_rad = np.radians(8)
839
+
840
+ # Newton iteration
841
+ F = F0
842
+ theta_rad = theta0_rad
843
+
844
+ for iteration in range(max_iter):
845
+ # Compute residuals
846
+ R1, R2, Q, y_q = compute_residuals(F, theta_rad)
847
+
848
+ if debug_level >= 1:
849
+ if iteration == 0:
850
+ print(f"Iteration {1} - Initial: F = {F:.3f}, theta = {np.degrees(theta_rad):.3f}°, R1 = {R1:.6e}, R2 = {R2:.6e}")
851
+ else:
852
+ print(f"Iteration {iteration + 1} - Updated: F = {F:.3f}, theta = {np.degrees(theta_rad):.3f}°, R1 = {R1:.6e}, R2 = {R2:.6e}")
853
+
854
+ # Check convergence
855
+ if abs(R1) < tol and abs(R2) < tol:
856
+ if debug_level >= 1:
857
+ print(f"Converged in {iteration + 1} iterations, R1 = {R1:.6e}, R2 = {R2:.6e}")
858
+ break
859
+
860
+ # Compute derivatives
861
+ dR1_dF, dR1_dtheta, dR2_dF, dR2_dtheta = compute_derivatives(F, theta_rad, Q, y_q)
862
+
863
+ # Basic Newton method (Equations 31-32)
864
+ # Build Jacobian matrix
865
+ J = np.array([[dR1_dF, dR1_dtheta],
866
+ [dR2_dF, dR2_dtheta]])
867
+
868
+ # Check condition number for numerical stability
869
+ try:
870
+ cond_num = np.linalg.cond(J)
871
+ if cond_num > 1e12:
872
+ return False, f"Ill-conditioned Jacobian matrix (condition number: {cond_num:.2e})"
873
+ except:
874
+ return False, "Unable to compute Jacobian condition number"
875
+
876
+ # Solve using matrix form for better numerical stability
877
+ try:
878
+ delta_solution = np.linalg.solve(J, np.array([-R1, -R2]))
879
+ delta_F = delta_solution[0]
880
+ delta_theta = delta_solution[1]
881
+ except np.linalg.LinAlgError:
882
+ return False, "Singular Jacobian matrix in Newton iteration"
883
+
884
+ if debug_level >= 1:
885
+ print(f" Newton: delta_F = {delta_F:.3f}, delta_theta = {np.degrees(delta_theta):.3f}°, {delta_theta: .3f} (rad)")
886
+
887
+ # Add step size control to prevent large jumps
888
+ max_delta_F = 0.5 # Maximum allowed change in F per iteration
889
+ max_delta_theta = np.radians(20) # Maximum allowed change in theta per iteration (20 degrees)
890
+
891
+ # Apply step size limiting
892
+ if abs(delta_F) > max_delta_F:
893
+ delta_F = np.sign(delta_F) * max_delta_F
894
+ if debug_level >= 1:
895
+ print(f" Step limited: delta_F clamped to {delta_F:.3f}")
896
+
897
+ if abs(delta_theta) > max_delta_theta:
898
+ delta_theta = np.sign(delta_theta) * max_delta_theta
899
+ if debug_level >= 1:
900
+ print(f" Step limited: delta_theta clamped to {np.degrees(delta_theta):.3f}°")
901
+
902
+ # Update values
903
+ F += delta_F
904
+ theta_rad += delta_theta
905
+
906
+ # Ensure F stays positive
907
+ if F <= 0:
908
+ F = 0.1
909
+
910
+ # Limit theta to reasonable range
911
+ theta_rad = np.clip(theta_rad, -np.pi/2, np.pi/2)
912
+
913
+ # Check if we converged
914
+ if iteration >= max_iter - 1:
915
+ return False, "Spencer's method did not converge within the maximum number of iterations."
916
+
917
+ # Final computation of Q and y_q
918
+ R1, R2, Q, y_q = compute_residuals(F, theta_rad)
919
+
920
+ if debug_level >= 2:
921
+ ma = 1 / (np.cos(alpha - theta_rad) + np.sin(alpha - theta_rad) * tan_p / F)
922
+ slice_df['ma'] = ma
923
+ slice_df['Q'] = Q
924
+ slice_df['y_q'] = y_q
925
+ slice_df['Fh'] = Fh
926
+ slice_df['Fv'] = Fv
927
+ slice_df['Mo'] = Mo
928
+ # Print F and theta to 12 decimal places
929
+ print(f"F = {F:.12f}, theta = {np.degrees(theta_rad):.12f}°")
930
+ # Report the residuals
931
+ print(f"R1 = {R1:.6e}, R2 = {R2:.6e}")
932
+ # Debug print values per slice
933
+ for i in range(len(Q)):
934
+ print(f"Slice {i+1}: ma = {ma[i]:.3f}, Q = {Q[i]:.1f}, y_q = {y_q[i]:.2f}, Fh = {Fh[i]:.1f}, Fv = {Fv[i]:.1f}, Mo = {Mo[i]:.2f}")
935
+
936
+
937
+ # Convert theta to degrees for output
938
+ theta_opt = np.degrees(theta_rad)
939
+
940
+ # ========== END SOLUTION ==========
941
+
942
+ # Store theta in df
943
+ slice_df['theta'] = theta_opt
944
+
945
+ # --- Compute N_eff using Equation (18) ---
946
+ N_eff = - Fv * cos_a + Fh * sin_a + Q * np.sin(alpha - theta_rad) - u * dl
947
+ slice_df['n_eff'] = N_eff
948
+
949
+ # --- Compute interslice forces Z using Equation (67) ---
950
+ n = len(Q)
951
+ Z = np.zeros(n+1)
952
+ for i in range(n):
953
+ Z[i+1] = Z[i] - Q[i]
954
+ slice_df['z'] = Z[:-1] # Z_i acting on slice i's left face
955
+
956
+
957
+ # --- Compute line of thrust using Equation (69) ---
958
+ yt_l = np.zeros(n) # the y-coordinate of the line of thrust on the left side of the slice.
959
+ yt_r = np.zeros(n) # the y-coordinate of the line of thrust on the right side of the slice.
960
+ yt_l[0] = y_lb[0]
961
+ sin_theta = np.sin(theta_rad)
962
+ cos_theta = np.cos(theta_rad)
963
+ for i in range(n):
964
+ if i == n - 1:
965
+ yt_r[i] = y_rb[i]
966
+ else:
967
+ yt_r[i] = y_b[i] - ((Mo[i] - Z[i] * sin_theta * dx[i] / 2 - Z[i+1] * sin_theta * dx[i] / 2 - Z[i] * cos_theta * (yt_l[i] - y_b[i])) / (Z[i+1] * cos_theta))
968
+ yt_l[i+1] = yt_r[i]
969
+ slice_df['yt_l'] = yt_l
970
+ slice_df['yt_r'] = yt_r
971
+
972
+ # --- Return results ---
973
+ results = {}
974
+ results['method'] = 'spencer'
975
+ results['FS'] = F
976
+ results['theta'] = theta_opt
977
+
978
+
979
+ return True, results
980
+
981
+
982
+ def spencer_OLD(df, tol=1e-4, max_iter = 100, debug_level=2):
983
+ """
984
+ Spencer's Method using Steve G. Wright's formulation from the UTEXAS v2 user manual.
985
+
986
+
987
+ Parameters:
988
+ df (pd.DataFrame): must contain columns
989
+ 'alpha' (slice base inclination, degrees),
990
+ 'phi' (slice friction angle, degrees),
991
+ 'c' (cohesion),
992
+ 'dl' (slice base length),
993
+ 'w' (slice weight),
994
+ 'u' (pore force per unit length),
995
+ 'd' (distributed load),
996
+ 'beta' (distributed load inclination, degrees),
997
+ 'kw' (seismic force),
998
+ 't' (tension crack water force),
999
+ 'p' (reinforcement force)
1000
+
1001
+ Returns:
1002
+ float: FS where FS_force = FS_moment
1003
+ float: beta (degrees)
1004
+ bool: converged flag
1005
+ """
1006
+
1007
+ alpha = np.radians(df['alpha'].values) # slice base inclination, degrees
1008
+ phi = np.radians(df['phi'].values) # slice friction angle, degrees
1009
+ c = df['c'].values # cohesion
1010
+ dx = df['dx'].values # slice width
1011
+ dl = df['dl'].values # slice base length
1012
+ W = df['w'].values # slice weight
1013
+ u = df['u'].values # pore presssure
1014
+ x_c = df['x_c'].values # center of base x-coordinate
1015
+ y_cb = df['y_cb'].values # center of base y-coordinate
1016
+ y_lb = df['y_lb'].values # left side base y-coordinate
1017
+ y_rb = df['y_rb'].values # right side base y-coordinate
1018
+ P = df['dload'].values # distributed load resultant
1019
+ beta = np.radians(df['beta'].values) # distributed load inclination, degrees
1020
+ kw = df['kw'].values # seismic force
1021
+ V = df['t'].values # tension crack water force
1022
+ y_v = df['y_t'].values # tension crack water force y-coordinate
1023
+ R = df['p'].values # reinforcement force
1024
+
1025
+ # For now, we assume that reinforcement is flexible and therefore is parallel to the failure surface
1026
+ # at the bottom of the slice. Therefore, the psi value used in the derivation is set to alpha,
1027
+ # and the point of action is the center of the base of the slice.
1028
+ psi = alpha # psi is the angle of the reinforcement force from the horizontal
1029
+ y_r = y_cb # y_r is the y-coordinate of the point of action of the reinforcement
1030
+ x_r = x_c # x_r is the x-coordinate of the point of action of the reinforcement
1031
+
1032
+ # use variable names to match the derivation.
1033
+ x_p = df['d_x'].values # distributed load x-coordinate
1034
+ y_p = df['d_y'].values # distributed load y-coordinate
1035
+ y_k = df['y_cg'].values # seismic force y-coordinate
1036
+ x_b = x_c # center of base x-coordinate
1037
+ y_b = y_cb # center of base y-coordinate
1038
+
1039
+ # pre-compute the trigonometric functions
1040
+ cos_a = np.cos(alpha) # cos(alpha)
1041
+ sin_a = np.sin(alpha) # sin(alpha)
1042
+ tan_p = np.tan(phi) # tan(phi)
1043
+ cos_b = np.cos(beta) # cos(beta)
1044
+ sin_b = np.sin(beta) # sin(beta)
1045
+ sin_psi = np.sin(psi) # sin(psi)
1046
+ cos_psi = np.cos(psi) # cos(psi)
1047
+
1048
+ Fh = - kw - V + P * sin_b + R * cos_psi # Equation (1)
1049
+ Fv = - W - P * cos_b + R * sin_psi # Equation (2)
1050
+ Mo = - P * sin_b * (y_p - y_b) - P * cos_b * (x_p - x_b) \
1051
+ + kw * (y_k - y_b) + V * (y_v - y_b) - R * cos_psi * (y_r - y_b) + R * sin_psi * (x_r - x_b) # Equation (3)
1052
+
1053
+ def compute_Q(F, theta_rad):
1054
+ ma = 1 / (np.cos(alpha - theta_rad) + np.sin(alpha - theta_rad) * tan_p / F) # Equation (24)
1055
+ Q = (- Fv * sin_a - Fh * cos_a - (c / F) * dl + (Fv * cos_a - Fh * sin_a + u * dl) * tan_p / F) * ma # Equation (23)
1056
+ y_q = y_b + Mo / (Q * np.cos(theta_rad)) # Equation (26)
1057
+ return Q, y_q
1058
+
1059
+ fs_min = 0.01
1060
+ fs_max = 20.0
1061
+
1062
+ def fs_force(theta_rad):
1063
+ def residual(F):
1064
+ Q, y_q = compute_Q(F, theta_rad)
1065
+ return Q.sum() # Equation (15)
1066
+ result = minimize_scalar(lambda F: abs(residual(F)), bounds=(fs_min, fs_max), method='bounded', options={'xatol': tol})
1067
+ return result.x
1068
+
1069
+ def fs_moment(theta_rad):
1070
+ def residual(F):
1071
+ Q, y_q = compute_Q(F, theta_rad)
1072
+ return np.sum(Q * (x_b * np.sin(theta_rad) - y_q * np.cos(theta_rad))) # Equation (16)
1073
+ result = minimize_scalar(lambda F: abs(residual(F)), bounds=(fs_min, fs_max), method='bounded', options={'xatol': tol})
1074
+ return result.x
1075
+
1076
+ def fs_difference(theta_deg):
1077
+ theta_rad = np.radians(theta_deg)
1078
+ Ff = fs_force(theta_rad)
1079
+ Fm = fs_moment(theta_rad)
1080
+ return Ff - Fm
1081
+
1082
+ # Robust theta root-finding with multiple strategies
1083
+ theta_opt = None
1084
+ convergence_error = None
1085
+
1086
+ # Strategy 1: Try multiple starting points for Newton's method
1087
+
1088
+ newton_starting_points = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
1089
+
1090
+ # Pre-evaluate fs_difference for all starting points and sort by absolute value
1091
+ starting_point_evaluations = []
1092
+ for theta_guess in newton_starting_points:
1093
+ try:
1094
+ fs_diff = fs_difference(theta_guess)
1095
+ starting_point_evaluations.append((theta_guess, abs(fs_diff), fs_diff))
1096
+ except Exception as e:
1097
+ if debug_level >= 1:
1098
+ print(f"Failed to evaluate fs_difference at {theta_guess:.1f} deg: {e}")
1099
+ continue
1100
+
1101
+ # Sort by absolute value of fs_difference (smallest first)
1102
+ starting_point_evaluations.sort(key=lambda x: x[1])
1103
+
1104
+ if debug_level >= 1:
1105
+ print("Starting points sorted by |fs_difference|:")
1106
+ for theta_guess, abs_fs_diff, fs_diff in starting_point_evaluations:
1107
+ print(f" {theta_guess:.1f}°: |fs_diff| = {abs_fs_diff:.6f}, fs_diff = {fs_diff:.6f}")
1108
+
1109
+ for theta_guess, abs_fs_diff, fs_diff in starting_point_evaluations:
1110
+ try:
1111
+ if debug_level >= 1:
1112
+ print(f"Trying Newton's method with initial guess {theta_guess:.1f} deg (|fs_diff| = {abs_fs_diff:.6f})")
1113
+ theta_candidate = newton(fs_difference, x0=theta_guess, tol=tol, maxiter=max_iter)
1114
+
1115
+ # Check if the solution is valid
1116
+ if (abs(theta_candidate) <= 59 and
1117
+ abs(fs_difference(theta_candidate)) <= 0.01 and
1118
+ fs_force(np.radians(theta_candidate)) < fs_max - 1e-3):
1119
+ theta_opt = theta_candidate
1120
+ if debug_level >= 1:
1121
+ print(f"Newton's method succeeded with starting point {theta_guess:.1f} deg")
1122
+ break
1123
+ except Exception as e:
1124
+ if debug_level >= 1:
1125
+ print(f"Newton's method failed with starting point {theta_guess:.1f} deg: {e}")
1126
+ continue
1127
+
1128
+ # Strategy 2: If Newton's method failed, try adaptive grid search
1129
+ if theta_opt is None:
1130
+ if debug_level >= 1:
1131
+ print("Newton's method failed for all starting points, trying adaptive grid search...")
1132
+
1133
+ # First, do a coarse sweep to identify promising regions
1134
+ theta_coarse = np.linspace(-60, 60, 121) # More points for better resolution
1135
+ fs_diff_coarse = []
1136
+
1137
+ for theta in theta_coarse:
1138
+ try:
1139
+ fs_diff_coarse.append(fs_difference(theta))
1140
+ except Exception:
1141
+ fs_diff_coarse.append(np.nan)
1142
+
1143
+ fs_diff_coarse = np.array(fs_diff_coarse)
1144
+
1145
+ # Find regions where sign changes occur
1146
+ sign_changes = []
1147
+ for i in range(len(fs_diff_coarse) - 1):
1148
+ if (not np.isnan(fs_diff_coarse[i]) and
1149
+ not np.isnan(fs_diff_coarse[i+1]) and
1150
+ fs_diff_coarse[i] * fs_diff_coarse[i+1] < 0):
1151
+ sign_changes.append((theta_coarse[i], theta_coarse[i+1]))
1152
+
1153
+ # Try root_scalar on each bracket
1154
+ for bracket in sign_changes:
1155
+ try:
1156
+ if debug_level >= 1:
1157
+ print(f"Trying root_scalar with bracket {bracket}")
1158
+ sol = root_scalar(fs_difference, bracket=bracket, method='brentq', xtol=tol)
1159
+ theta_candidate = sol.root
1160
+
1161
+ # Check if the solution is valid
1162
+ if (abs(theta_candidate) <= 59 and
1163
+ abs(fs_difference(theta_candidate)) <= 0.01 and
1164
+ fs_force(np.radians(theta_candidate)) < fs_max - 1e-3):
1165
+ theta_opt = theta_candidate
1166
+ if debug_level >= 1:
1167
+ print(f"root_scalar succeeded with bracket {bracket}")
1168
+ break
1169
+ except Exception as e:
1170
+ if debug_level >= 1:
1171
+ print(f"root_scalar failed with bracket {bracket}: {e}")
1172
+ continue
1173
+
1174
+ # Strategy 3: If still no solution, try global optimization
1175
+ if theta_opt is None:
1176
+ if debug_level >= 1:
1177
+ print("All root-finding methods failed, trying global optimization...")
1178
+
1179
+ try:
1180
+ # Use minimize_scalar to find the minimum of |fs_difference|
1181
+ result = minimize_scalar(
1182
+ lambda theta: abs(fs_difference(theta)),
1183
+ bounds=(-60, 60),
1184
+ method='bounded',
1185
+ options={'xatol': tol}
1186
+ )
1187
+
1188
+ if result.success and abs(fs_difference(result.x)) <= 0.01:
1189
+ theta_opt = result.x
1190
+ if debug_level >= 1:
1191
+ print(f"Global optimization succeeded with theta = {theta_opt:.6f} deg")
1192
+ else:
1193
+ convergence_error = f"Global optimization failed: {result.message}"
1194
+
1195
+ except Exception as e:
1196
+ convergence_error = f"Global optimization failed: {e}"
1197
+
1198
+ # Check if we found a solution
1199
+ if theta_opt is None:
1200
+ if convergence_error:
1201
+ return False, f"Spencer's method failed to converge: {convergence_error}"
1202
+ else:
1203
+ return False, "Spencer's method: No valid solution found with any method."
1204
+
1205
+ theta_rad = np.radians(theta_opt)
1206
+ FS_force = fs_force(theta_rad)
1207
+ FS_moment = fs_moment(theta_rad)
1208
+
1209
+ df['theta'] = theta_opt # store theta in df.
1210
+
1211
+ # --- Compute N_eff ---
1212
+ Q, y_q = compute_Q(FS_force, theta_rad)
1213
+ N_eff = - Fv * cos_a + Fh * sin_a + Q * np.sin(alpha - theta_rad) - u * dl # Equation (18)
1214
+
1215
+ # --- compute interslice forces Z ---
1216
+ n = len(Q)
1217
+ Z = np.zeros(n+1)
1218
+ for i in range(n):
1219
+ Z[i+1] = Z[i] - Q[i]
1220
+
1221
+ # --- store back into df ---
1222
+ df['z'] = Z[:-1] # Z_i acting on slice i's left face
1223
+ df['n_eff'] = N_eff
1224
+
1225
+ # --- compute line of thrust ---
1226
+ yt_l = np.zeros(n) # the y-coordinate of the line of thrust on the left side of the slice.
1227
+ yt_r = np.zeros(n) # the y-coordinate of the line of thrust on the right side of the slice.
1228
+ yt_l[0] = y_lb[0]
1229
+ sin_theta = np.sin(theta_rad)
1230
+ cos_theta = np.cos(theta_rad)
1231
+ for i in range(n):
1232
+ if i == n - 1:
1233
+ yt_r[i] = y_rb[i]
1234
+ else:
1235
+ yt_r[i] = y_b[i] - ((Mo[i] - Z[i] * sin_theta * dx[i] / 2 - Z[i+1] * sin_theta * dx[i] / 2 - Z[i] * cos_theta * (yt_l[i] - y_b[i])) / (Z[i+1] * cos_theta)) # Equation (30)
1236
+ yt_l[i+1] = yt_r[i]
1237
+ df['yt_l'] = yt_l
1238
+ df['yt_r'] = yt_r
1239
+
1240
+ # --- Check convergence ---
1241
+ converged = abs(FS_force - FS_moment) < tol
1242
+ if not converged:
1243
+ return False, "Spencer's method did not converge within the maximum number of iterations."
1244
+ else:
1245
+ results = {}
1246
+ results['method'] = 'spencer'
1247
+ results['FS'] = FS_force
1248
+ results['theta'] = theta_opt
1249
+
1250
+ # debug print values per slice
1251
+ if debug_level >= 2:
1252
+ for i in range(len(Q)):
1253
+ print(f"Slice {i+1}: Q = {Q[i]:.1f}, y_q = {y_q[i]:.2f}, Fh = {Fh[i]:.1f}, Fv = {Fv[i]:.1f}, Mo = {Mo[i]:.2f}")
1254
+
1255
+ return True, results
1256
+
1257
+
1258
+
1259
+